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 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 09:54:36 -04:00
co-authored by Claude Opus 5.5
parent 59e072602b
commit bce2792d8c
2 changed files with 85 additions and 11 deletions
+47 -10
View File
@@ -44,19 +44,56 @@ LOGGER = logging.getLogger(__name__)
_RETRY_BACKOFF_SECONDS = 60.0 _RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float: #: Upper bound on the minute-by-minute search past a nonexistent wall
"""Seconds from ``now`` until the next occurrence of ``target``. #: 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 def _resolve_wall_time(
tomorrow, so the just-run sweep is never instantly re-run). 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( wall = datetime.datetime.combine(
hour=target.hour, minute=target.minute, second=0, microsecond=0 day, datetime.time(target.hour, target.minute)
) )
if scheduled <= now: for _ in range(_MAX_GAP_MINUTES):
scheduled += datetime.timedelta(days=1) candidate = wall.replace(tzinfo=zone)
return (scheduled - now).total_seconds() 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: class MissedSweepScheduler:
+38 -1
View File
@@ -32,7 +32,7 @@ import pytest
from api import routes_admin, routes_panel from api import routes_admin, routes_panel
from api.database import _executor from api.database import _executor
from api.scheduler import MissedSweepScheduler from api.scheduler import MissedSweepScheduler, _delay_seconds
from tests.admin_jwt_harness import ( from tests.admin_jwt_harness import (
ADMIN_KEY, ADMIN_KEY,
KID, KID,
@@ -270,3 +270,40 @@ async def test_scheduler_waits_for_rollover_in_household_time(
assert delays == [pytest.approx(65 * 60)] assert delays == [pytest.approx(65 * 60)]
finally: finally:
await database.close() 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