Files
changedetection.io/changedetectionio/model/shared_defaults.py
dgtlmoonandClaude Opus 5.5 ab06c89c73 Watch time_schedule_limit - Typed, frozen pydantic model with shared defaults, halves per-watch memory
Every watch carried its own copy of the 7-day schedule (15 nested dicts, ~30 strings),
about 3.7KiB of a ~7.6KiB watch, even though nearly every schedule is the untouched
default. At 40,000 watches that is ~146MB.

time_schedule_limit is now a slots + frozen pydantic dataclass (model/TimeScheduleLimit.py)
built on a small generic base (model/shared_defaults.py):

- Anything equal to the default is the one shared default instance, recursively, so an
  untouched day or the whole default schedule costs nothing per watch.
- watch.json only stores what differs from the default (exclude_defaults), an enabled
  schedule with default days is {"enabled": true}, a disabled custom week is kept.
- Frozen, so the shared default can never be mutated in place, assign a new value.
- Every write path (loading, edit form, API, clone) converts in watch_base __setitem__/update.
- Blank/None fields fall back per field, one empty value no longer loses the whole week.
- hours/minutes are ints, in the model, the form and the API spec (no upper bound,
  multi-day windows are supported).
- The scheduler converts the global schedule once per pass instead of reading it per watch.

update_34 rewrites watch.json/tag.json compact and converts the global schedule's
"24"/"00" strings to ints.

Measured: 7.6KiB -> 3.9KiB per watch (296MiB -> 150MiB at 40k watches).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:00:22 +02:00

79 lines
3.1 KiB
Python

"""
Base class for small, immutable (frozen) pydantic dataclasses that hang off a Watch/Tag.
Any field whose value equals its declared default is swapped for that one default
instance, recursively, so the default is held once per process instead of once per watch.
With 40,000 watches a plain nested dict of defaults (e.g. the 7-day time_schedule_limit,
~3.7KiB each) costs ~150MB; shared, it costs nothing.
On disk the same rule applies via to_storage() (exclude_defaults), so watch.json only
holds what differs from the default and loads straight back onto the shared instances.
Subclasses must be declared with @pydantic.dataclasses.dataclass(slots=True, frozen=True),
frozen is what makes sharing safe - nothing can mutate the shared default in place,
replace the value instead.
"""
import dataclasses
from typing import ClassVar
from pydantic import TypeAdapter, model_validator
class DefaultShared:
__slots__ = ()
# Opt-in per model: treat None/blank values as "use this field's default" instead of failing
# validation. Stops one empty field in an old watch.json (or form post) from throwing away the
# whole structure. Off by default because for some fields '' may be a deliberate value.
BLANK_MEANS_DEFAULT: ClassVar[bool] = False
@model_validator(mode='before')
@classmethod
def _blank_means_default(cls, data):
if cls.BLANK_MEANS_DEFAULT and isinstance(data, dict):
return {k: v for k, v in data.items()
if not (v is None or (isinstance(v, str) and not v.strip()))}
return data
@model_validator(mode='after')
def _share_defaults(self):
# Pydantic validates bottom-up, so nested values are already canonical by the
# time the parent compares them, which makes the == below cheap (identity first)
for f in dataclasses.fields(self):
if f.default is dataclasses.MISSING:
continue
value = getattr(self, f.name)
if value is not f.default and value == f.default:
object.__setattr__(self, f.name, f.default)
return self
# Immutable, so copies can share the same instance (Watch objects are deepcopied on every check)
def __copy__(self):
return self
def __deepcopy__(self, memo):
return self
@classmethod
def _adapter(cls):
adapter = cls.__dict__.get('_type_adapter')
if adapter is None:
adapter = TypeAdapter(cls)
type.__setattr__(cls, '_type_adapter', adapter)
return adapter
@classmethod
def validate(cls, value):
"""dict (from watch.json, the form or the API) -> instance"""
if isinstance(value, cls):
return value
return cls._adapter().validate_python(value)
def to_storage(self):
"""Only what differs from the defaults, for watch.json"""
return self._adapter().dump_python(self, mode='json', exclude_defaults=True)
def to_api(self):
"""The full structure for API responses, unset (None) values are left out so a GET can be PUT straight back"""
return self._adapter().dump_python(self, mode='json', exclude_none=True)