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

+7 -1
View File
@@ -30,9 +30,12 @@ class BackupSchedule(SQLModel, table=True):
all_stacks: bool = False
destination_id: int
frequency: str = "daily" # one of FREQUENCIES
hour: int = 3 # UTC, used for daily/weekly
hour: int = 3 # wall-clock time in ``timezone``, used for daily/weekly
minute: int = 0
weekday: int = 0 # 0=Mon .. 6=Sun, used for weekly
#: IANA zone the time is in ("Europe/Berlin"). Schedules from before
#: 0.66.0 were UTC and stay so until edited.
timezone: str = "UTC"
include_volumes: bool = True
stop_first: bool = True
keep: int = 7 # retention: keep newest N for this stack on the dest (0 = all)
@@ -88,6 +91,7 @@ class ScheduleCreate(SQLModel):
hour: int = 3
minute: int = 0
weekday: int = 0
timezone: str = "UTC"
include_volumes: bool = True
stop_first: bool = True
keep: int = 7
@@ -102,6 +106,7 @@ class ScheduleUpdate(SQLModel):
hour: Optional[int] = None
minute: Optional[int] = None
weekday: Optional[int] = None
timezone: Optional[str] = None
include_volumes: Optional[bool] = None
stop_first: Optional[bool] = None
keep: Optional[int] = None
@@ -120,6 +125,7 @@ class ScheduleRead(SQLModel):
hour: int
minute: int
weekday: int
timezone: str
include_volumes: bool
stop_first: bool
keep: int
+2
View File
@@ -15,3 +15,5 @@ httpx==0.28.1
PyYAML==6.0.2
paramiko==3.5.0
boto3==1.35.99
# Time zones for backup schedules: the slim base image has no system tz database.
tzdata==2026.4
+13 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
@@ -45,6 +46,7 @@ def _to_read(session: Session, s: BackupSchedule) -> ScheduleRead:
hour=s.hour,
minute=s.minute,
weekday=s.weekday,
timezone=s.timezone or "UTC",
include_volumes=s.include_volumes,
stop_first=s.stop_first,
keep=s.keep,
@@ -68,6 +70,14 @@ def _get_or_404(session: Session, schedule_id: int) -> BackupSchedule:
def _validate(session: Session, schedule: BackupSchedule) -> None:
if schedule.frequency not in FREQUENCIES:
raise HTTPException(status_code=400, detail=f"Unknown frequency '{schedule.frequency}'")
if not (0 <= schedule.hour <= 23 and 0 <= schedule.minute <= 59 and 0 <= schedule.weekday <= 6):
raise HTTPException(status_code=400, detail="Hour must be 0-23, minute 0-59, weekday 0-6")
try:
ZoneInfo(schedule.timezone or "UTC")
except (ZoneInfoNotFoundError, ValueError) as exc:
raise HTTPException(
status_code=400, detail=f"Unknown time zone '{schedule.timezone}'"
) from exc
if not session.get(BackupDestination, schedule.destination_id):
raise HTTPException(status_code=404, detail=f"Destination {schedule.destination_id} not found")
if schedule.all_stacks:
@@ -101,7 +111,7 @@ def create_schedule(
set_stacks(s, body.stack_ids or ([body.stack_id] if body.stack_id else []))
_validate(session, s)
s.next_run = schedule_service.compute_next_run(
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now()
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now(), s.timezone
)
session.add(s)
session.commit()
@@ -130,9 +140,9 @@ def update_schedule(
set_stacks(s, stack_ids)
_validate(session, s)
# Recompute next run when timing fields change.
if {"frequency", "hour", "minute", "weekday"} & set(data) or s.next_run is None:
if {"frequency", "hour", "minute", "weekday", "timezone"} & set(data) or s.next_run is None:
s.next_run = schedule_service.compute_next_run(
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now()
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now(), s.timezone
)
session.add(s)
session.commit()
+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)
+73
View File
@@ -309,3 +309,76 @@ def test_stack_status_is_stored_as_json(monkeypatch, db):
run(env.svc.run_schedule(env.session, schedule))
stored = json.loads(schedule.stack_status)
assert stored[env.ids["a"]]["status"] == "ok" and "at" in stored[env.ids["a"]]
# --------------------------------------------------------------------------- #
# Local time
# --------------------------------------------------------------------------- #
def _next(freq, hour, minute, weekday, after, tz):
from datetime import datetime
from services import schedule_service
return schedule_service.compute_next_run(
freq, hour, minute, weekday, datetime.fromisoformat(after), tz
).isoformat()
def test_daily_time_is_wall_clock_time_in_the_schedule_zone():
# 03:00 in Berlin is 02:00 UTC in winter...
assert _next("daily", 3, 0, 0, "2026-01-10T12:00", "Europe/Berlin") == "2026-01-11T02:00:00"
# ...and 01:00 UTC in summer.
assert _next("daily", 3, 0, 0, "2026-07-10T12:00", "Europe/Berlin") == "2026-07-11T01:00:00"
def test_daily_time_stays_put_across_daylight_saving_changes():
# Clocks go forward on 29 March 2026 and back on 25 October 2026.
assert _next("daily", 3, 0, 0, "2026-03-28T02:00", "Europe/Berlin") == "2026-03-29T01:00:00"
assert _next("daily", 3, 0, 0, "2026-03-29T01:00", "Europe/Berlin") == "2026-03-30T01:00:00"
assert _next("daily", 3, 0, 0, "2026-10-24T01:00", "Europe/Berlin") == "2026-10-25T02:00:00"
def test_a_time_skipped_by_the_clock_change_runs_an_hour_later():
# 02:30 does not exist on 29 March in Berlin; it runs at 03:30 local.
assert _next("daily", 2, 30, 0, "2026-03-28T12:00", "Europe/Berlin") == "2026-03-29T01:30:00"
def test_weekly_is_on_the_local_weekday():
# Sunday 00:30 in Berlin is still Saturday in UTC.
assert _next("weekly", 0, 30, 6, "2026-01-05T12:00", "Europe/Berlin") == "2026-01-10T23:30:00"
def test_hourly_minute_is_local_minute():
# India is UTC+5:30, so :00 local is :30 UTC.
assert _next("hourly", 0, 0, 0, "2026-01-10T12:00", "Asia/Kolkata") == "2026-01-10T12:30:00"
assert _next("hourly", 0, 15, 0, "2026-01-10T12:00", "UTC") == "2026-01-10T12:15:00"
def test_utc_is_the_default_and_unknown_zones_fall_back_to_it():
assert _next("daily", 3, 0, 0, "2026-01-10T12:00", None) == "2026-01-11T03:00:00"
assert _next("daily", 3, 0, 0, "2026-01-10T12:00", "Mars/Olympus") == "2026-01-11T03:00:00"
def test_api_keeps_the_zone_and_refuses_bad_values(api_env):
env, client = api_env
r = client.post("/api/backups/schedules", json={
"stack_ids": [env.ids["a"]], "destination_id": env.dest.id,
"hour": 3, "minute": 0, "timezone": "Europe/Berlin",
})
assert r.status_code == 201
body = r.json()
assert body["timezone"] == "Europe/Berlin"
from datetime import datetime
from zoneinfo import ZoneInfo
local = datetime.fromisoformat(body["next_run"]).replace(tzinfo=ZoneInfo("UTC")).astimezone(
ZoneInfo("Europe/Berlin")
)
assert (local.hour, local.minute) == (3, 0)
r = client.put(f"/api/backups/schedules/{body['id']}", json={"timezone": "Mars/Olympus"})
assert r.status_code == 400
r = client.put(f"/api/backups/schedules/{body['id']}", json={"hour": 24})
assert r.status_code == 400
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.65.0"
APP_VERSION = "0.66.0"