Schedule backups in local time instead of UTC (0.66.0)
Schedule times were UTC, so a nightly 03:00 backup had to be entered as 01:00 or 02:00 depending on the season, and moved by an hour twice a year. Each schedule now has an IANA time zone, preset in the form to the browser's (e.g. Europe/Berlin), and its hour/minute/weekday are wall-clock time there. compute_next_run builds each candidate day from its date in that zone rather than adding 24 hours, so the time stays put across daylight saving changes; a time skipped by the spring change runs an hour later that day. next_run is still stored as naive UTC. - Existing schedules default to UTC and keep their run times until edited. - The API validates the zone and hour/minute/weekday ranges (400) instead of silently wrapping them. - tzdata is a dependency: the slim base image has no system tz database. Tests cover Berlin winter and summer time, both 2026 change days, the skipped hour, a weekly run across the UTC date line, hourly at a +5:30 offset, the UTC fallback and the API checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a0d673798
commit
9a357d2b92
10 files changed
+192
-27
No files matched your search
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -55,28 +56,57 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def zone(name: str | None) -> ZoneInfo:
|
||||
"""The schedule's time zone; UTC for an empty or unknown name."""
|
||||
try:
|
||||
return ZoneInfo(name or "UTC")
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def compute_next_run(
|
||||
frequency: str, hour: int, minute: int, weekday: int, after: datetime
|
||||
frequency: str,
|
||||
hour: int,
|
||||
minute: int,
|
||||
weekday: int,
|
||||
after: datetime,
|
||||
tz: str | None = "UTC",
|
||||
) -> datetime:
|
||||
"""Next run strictly after ``after`` (naive UTC)."""
|
||||
"""Next run strictly after ``after`` (naive UTC), returned as naive UTC.
|
||||
|
||||
``hour``/``minute``/``weekday`` are wall-clock time in ``tz``, so a 03:00
|
||||
backup stays at 03:00 across daylight saving changes. Each candidate day is
|
||||
built from its own date rather than by adding 24 hours, which is what keeps
|
||||
it on the wall clock. A time that does not exist on the day clocks go
|
||||
forward (02:30) runs an hour later that day.
|
||||
"""
|
||||
zi = zone(tz)
|
||||
after_utc = after.replace(tzinfo=timezone.utc)
|
||||
local = after_utc.astimezone(zi)
|
||||
|
||||
def utc(dt: datetime) -> datetime:
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if frequency == "hourly":
|
||||
nxt = after.replace(minute=minute % 60, second=0, microsecond=0)
|
||||
if nxt <= after:
|
||||
cand = local.replace(minute=minute % 60, second=0, microsecond=0)
|
||||
# Step in UTC: an hour is an hour, whatever the wall clock does.
|
||||
nxt = cand.astimezone(timezone.utc)
|
||||
while nxt <= after_utc:
|
||||
nxt += timedelta(hours=1)
|
||||
return nxt
|
||||
return nxt.replace(tzinfo=None)
|
||||
|
||||
base = after.replace(hour=hour % 24, minute=minute % 60, second=0, microsecond=0)
|
||||
def at(day) -> datetime:
|
||||
return datetime(day.year, day.month, day.day, hour % 24, minute % 60, tzinfo=zi)
|
||||
|
||||
day = local.date()
|
||||
if frequency == "weekly":
|
||||
days_ahead = (weekday - base.weekday()) % 7
|
||||
cand = base + timedelta(days=days_ahead)
|
||||
if cand <= after:
|
||||
cand += timedelta(days=7)
|
||||
return cand
|
||||
|
||||
# daily (default)
|
||||
if base <= after:
|
||||
base += timedelta(days=1)
|
||||
return base
|
||||
day += timedelta(days=(weekday - day.weekday()) % 7)
|
||||
step = timedelta(days=7)
|
||||
else: # daily (default)
|
||||
step = timedelta(days=1)
|
||||
while at(day).astimezone(timezone.utc) <= after_utc:
|
||||
day += step
|
||||
return utc(at(day))
|
||||
|
||||
|
||||
def _prune(dest: BackupDestination, stack_id: str, keep: int) -> int:
|
||||
@@ -216,7 +246,8 @@ async def run_schedule(session: Session, schedule: BackupSchedule) -> dict:
|
||||
)
|
||||
schedule.last_run = now
|
||||
schedule.next_run = compute_next_run(
|
||||
schedule.frequency, schedule.hour, schedule.minute, schedule.weekday, now
|
||||
schedule.frequency, schedule.hour, schedule.minute, schedule.weekday, now,
|
||||
schedule.timezone,
|
||||
)
|
||||
session.add(schedule)
|
||||
session.commit()
|
||||
@@ -241,7 +272,9 @@ async def run_due() -> None:
|
||||
due = []
|
||||
for s in schedules:
|
||||
if s.next_run is None:
|
||||
s.next_run = compute_next_run(s.frequency, s.hour, s.minute, s.weekday, now)
|
||||
s.next_run = compute_next_run(
|
||||
s.frequency, s.hour, s.minute, s.weekday, now, s.timezone
|
||||
)
|
||||
session.add(s)
|
||||
elif s.next_run <= now:
|
||||
due.append(s)
|
||||
|
||||
Reference in new issue
Block a user