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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.65.0"
|
||||
APP_VERSION = "0.66.0"
|
||||
Reference in new issue
Block a user