mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-27 09:05:46 +00:00
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>
77 lines
2.9 KiB
Python
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
|