From bce2792d8c7547bd1d99fb896816edbc9b6f0d4f Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 26 Sep 2026 09:54:36 -0400 Subject: [PATCH] Compute the missed-sweep delay as real elapsed time across DST _delay_seconds subtracted two aware datetimes sharing the household ZoneInfo, which Python does as wall-clock arithmetic, so the sweep fired an hour late at spring-forward and an hour early at fall-back. Both instants are now compared and subtracted in UTC. A nonexistent rollover time resolves to the first valid wall minute at/after it (the gap's end); an ambiguous one resolves to its first occurrence (fold=0). Adds America/New_York spring-forward, fall-back, nonexistent and ambiguous cases alongside the ordinary EDT case. Co-Authored-By: Claude Opus 5.5 --- api/scheduler.py | 57 +++++++++++++++++++++++++------ tests/test_api_household_clock.py | 39 ++++++++++++++++++++- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/api/scheduler.py b/api/scheduler.py index 092447b..30498ea 100644 --- a/api/scheduler.py +++ b/api/scheduler.py @@ -44,19 +44,56 @@ LOGGER = logging.getLogger(__name__) _RETRY_BACKOFF_SECONDS = 60.0 -def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float: - """Seconds from ``now`` until the next occurrence of ``target``. +#: Upper bound on the minute-by-minute search past a nonexistent wall +#: time; real DST gaps are an hour, the largest ever skipped a day. +_MAX_GAP_MINUTES = 2 * 24 * 60 - ``target`` is a plain ``HH:MM`` time of day: the next wall-clock - occurrence strictly AFTER ``now`` (an equal time schedules for - tomorrow, so the just-run sweep is never instantly re-run). + +def _resolve_wall_time( + day: datetime.date, target: datetime.time, zone: datetime.tzinfo +) -> datetime.datetime: + """The aware instant ``target`` (``HH:MM``) names on ``day`` in ``zone``. + + A nonexistent wall time (inside a spring-forward gap) resolves to + the first valid wall minute at/after it — the gap's end, e.g. 02:30 + on a 02:00→03:00 day becomes 03:00. An ambiguous wall time (the + repeated fall-back hour) resolves to its FIRST occurrence + (``fold=0``). """ - scheduled = now.replace( - hour=target.hour, minute=target.minute, second=0, microsecond=0 + wall = datetime.datetime.combine( + day, datetime.time(target.hour, target.minute) ) - if scheduled <= now: - scheduled += datetime.timedelta(days=1) - return (scheduled - now).total_seconds() + for _ in range(_MAX_GAP_MINUTES): + candidate = wall.replace(tzinfo=zone) + round_trip = candidate.astimezone(datetime.timezone.utc).astimezone( + zone + ) + if round_trip.replace(tzinfo=None) == wall: + return candidate + wall += datetime.timedelta(minutes=1) + raise ValueError(f"no valid wall time at/after {target} on {day}") + + +def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float: + """Real seconds from ``now`` until the next occurrence of ``target``. + + ``target`` is a plain ``HH:MM`` household time of day, resolved in + ``now``'s zone by :func:`_resolve_wall_time` (nonexistent → first + valid instant after the gap; ambiguous → first occurrence). The + result is the next such instant strictly AFTER ``now`` (an equal + time schedules for tomorrow, so the just-run sweep is never + instantly re-run). Both instants are compared and subtracted in + UTC: same-``tzinfo`` aware arithmetic is wall-clock arithmetic and + would be an hour off across a DST transition. + """ + now_utc = now.astimezone(datetime.timezone.utc) + day = now.date() + while True: + scheduled = _resolve_wall_time(day, target, now.tzinfo) + scheduled_utc = scheduled.astimezone(datetime.timezone.utc) + if scheduled_utc > now_utc: + return (scheduled_utc - now_utc).total_seconds() + day += datetime.timedelta(days=1) class MissedSweepScheduler: diff --git a/tests/test_api_household_clock.py b/tests/test_api_household_clock.py index fd8aa99..cd08f84 100644 --- a/tests/test_api_household_clock.py +++ b/tests/test_api_household_clock.py @@ -32,7 +32,7 @@ import pytest from api import routes_admin, routes_panel from api.database import _executor -from api.scheduler import MissedSweepScheduler +from api.scheduler import MissedSweepScheduler, _delay_seconds from tests.admin_jwt_harness import ( ADMIN_KEY, KID, @@ -270,3 +270,40 @@ async def test_scheduler_waits_for_rollover_in_household_time( assert delays == [pytest.approx(65 * 60)] finally: await database.close() + + +# --- the rollover delay across DST (America/New_York) ------------------------ + +NEW_YORK = ZoneInfo("America/New_York") + + +def _ny(*args: int, fold: int = 0) -> datetime.datetime: + return datetime.datetime(*args, tzinfo=NEW_YORK, fold=fold) + + +@pytest.mark.parametrize( + ("now", "target", "expected"), + [ + # Ordinary EDT day: 09:00 -> 10:05 is 65 minutes. + (_ny(2026, 6, 15, 9, 0), datetime.time(10, 5), 65 * 60), + # An equal time schedules for tomorrow (strictly after now). + (_ny(2026, 6, 15, 10, 5), datetime.time(10, 5), 86400), + # Spring-forward: 01:00 EST -> 03:00 EDT is one real hour. + (_ny(2026, 3, 8, 1, 0), datetime.time(3, 0), 3600), + # Fall-back: 00:30 EDT -> 02:00 EST is two and a half real hours. + (_ny(2026, 11, 1, 0, 30), datetime.time(2, 0), 9000), + # Nonexistent 02:30 on spring-forward day: the first valid instant + # at/after it is 03:00 EDT, one real hour after 01:00 EST. + (_ny(2026, 3, 8, 1, 0), datetime.time(2, 30), 3600), + # Ambiguous 01:30 on fall-back day resolves to the FIRST (EDT) + # occurrence: from 00:30 EDT that is one real hour. + (_ny(2026, 11, 1, 0, 30), datetime.time(1, 30), 3600), + # Already past the first 01:30 (now is 01:15 EST, the repeated + # hour): not the second occurrence, but tomorrow's 01:30 EST. + (_ny(2026, 11, 1, 1, 15, fold=1), datetime.time(1, 30), 87300), + ], +) +def test_delay_seconds_is_true_elapsed_time_across_dst( + now: datetime.datetime, target: datetime.time, expected: float +) -> None: + assert _delay_seconds(target, now) == expected -- 2.55.0