Schedule backups in local time instead of UTC (0.66.0)
CI / check (push) Successful in 13m5s
CI / build-and-push (push) Successful in 3m3s

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:
menzeljandClaude Opus 5 committed 2026-09-18 12:50:46 +02:00
1 parent 8a0d673798
commit 9a357d2b92
10 files changed
+192 -27

No files matched your search

+51 -18
View File
@@ -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)