Files
changedetection.io/changedetectionio/model/TimeScheduleLimit.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

77 lines
2.9 KiB
Python

"""
Typed model for the weekly check schedule, stored at
watch['time_schedule_limit'] / tag['time_schedule_limit']
and (still as a plain dict for now) at
datastore.data['settings']['requests']['time_schedule_limit']
Untouched days share one DaySchedule instance across every watch, see shared_defaults.py
"""
from typing import ClassVar, Optional
from loguru import logger
from pydantic import Field, ValidationError
from pydantic.dataclasses import dataclass
from .shared_defaults import DefaultShared
DAYS_OF_WEEK = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
@dataclass(slots=True, frozen=True)
class Duration(DefaultShared):
BLANK_MEANS_DEFAULT: ClassVar[bool] = True
# No upper bound, multi-day windows (e.g. 48 hours) are supported by time_handler
hours: int = Field(default=24, ge=0)
minutes: int = Field(default=0, ge=0)
@dataclass(slots=True, frozen=True)
class DaySchedule(DefaultShared):
BLANK_MEANS_DEFAULT: ClassVar[bool] = True
enabled: bool = True
start_time: str = Field(default='00:00', pattern=r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$')
duration: Duration = Duration()
@dataclass(slots=True, frozen=True)
class TimeScheduleLimit(DefaultShared):
BLANK_MEANS_DEFAULT: ClassVar[bool] = True # e.g. the form submits timezone '' for "not set"
enabled: bool = False
timezone: Optional[str] = None
monday: DaySchedule = DaySchedule()
tuesday: DaySchedule = DaySchedule()
wednesday: DaySchedule = DaySchedule()
thursday: DaySchedule = DaySchedule()
friday: DaySchedule = DaySchedule()
saturday: DaySchedule = DaySchedule()
sunday: DaySchedule = DaySchedule()
def day(self, day_name):
return getattr(self, day_name.lower())
DEFAULT_TIME_SCHEDULE_LIMIT = TimeScheduleLimit()
def as_time_schedule_limit(value, owner_uuid=None):
"""
Anything stored or submitted -> TimeScheduleLimit, values equal to the default become
the shared DEFAULT_TIME_SCHEDULE_LIMIT.
Lenient on purpose, this runs while loading watch.json. A broken stored value must not
stop the watch from loading, so it falls back to the default and logs. Blank/None fields
already fall back to their own default (BLANK_MEANS_DEFAULT), so this only happens for
genuinely unparseable values. The API checks input strictly before it gets here
(api/Watch.py validate_time_schedule_limit()).
owner_uuid is only for the log line, so the right watch/tag can be restored from a backup.
"""
if not value:
return DEFAULT_TIME_SCHEDULE_LIMIT
try:
schedule = TimeScheduleLimit.validate(value)
except ValidationError as e:
logger.error(f"{owner_uuid or 'Unknown watch/tag'} - Invalid time_schedule_limit {value!r}, using the default (no schedule) - {e}")
return DEFAULT_TIME_SCHEDULE_LIMIT
return DEFAULT_TIME_SCHEDULE_LIMIT if schedule == DEFAULT_TIME_SCHEDULE_LIMIT else schedule