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
@@ -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
|
||||
Reference in new issue
Block a user