Files
openswarm/backend/tests/test_cloud_schedule_agreement.py

122 lines
6.5 KiB
Python

"""The two schedulers have to agree on when "9am Monday" is.
A workflow can sit on this machine or on our servers, and the user is told a "next run" either way.
Two recurrence engines in two languages means two chances to be wrong, and the place they would
diverge is exactly the place wall-clock scheduling is hard: the morning a clock jumps forward and
2:30am never happens, and the morning it falls back and 1:30am happens twice.
The vector table below is duplicated verbatim in the cloud service's tests/workflow-schedule.test.ts.
Each row asserts the same answer on both sides, so if either engine drifts one of the suites fails.
Nothing here talks to the network; both halves are pure functions of a schedule and a moment.
"""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from backend.apps.workflows.cloud.schedule import (
CloudDailySchedule,
CloudWeeklySchedule,
ScheduleSupported,
to_cloud_schedule,
wire,
)
from backend.apps.workflows.models import ScheduleConfig, Workflow
from backend.apps.workflows.scheduler import compute_next_fire
LA = "America/Los_Angeles"
# (label, ScheduleConfig kwargs, asked at, fires at). Times are UTC because that is what both
# engines return; the point of each row is the LOCAL clock it corresponds to, named in the label.
VECTORS = [
("LA daily 9am, the Saturday before the clocks go forward",
dict(repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone=LA),
"2026-03-06T20:00:00Z", "2026-03-07T17:00:00Z"),
("LA daily 9am, the day the clocks go forward",
dict(repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone=LA),
"2026-03-07T20:00:00Z", "2026-03-08T16:00:00Z"),
("LA daily 9am, the day the clocks go back",
dict(repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone=LA),
"2026-10-31T20:00:00Z", "2026-11-01T17:00:00Z"),
("LA weekdays 9am, asked Friday after the slot, over a spring-forward weekend",
dict(repeat_unit="week", repeat_every=1, on_days=[1, 2, 3, 4, 5], hour=9, minute=0, timezone=LA),
"2026-03-06T18:00:00Z", "2026-03-09T16:00:00Z"),
("LA weekdays 9am, asked Friday after the slot, over a fall-back weekend",
dict(repeat_unit="week", repeat_every=1, on_days=[1, 2, 3, 4, 5], hour=9, minute=0, timezone=LA),
"2026-10-30T18:00:00Z", "2026-11-02T17:00:00Z"),
("LA weekends 9am, asked on a Wednesday",
dict(repeat_unit="week", repeat_every=1, on_days=[0, 6], hour=9, minute=0, timezone=LA),
"2026-06-10T18:00:00Z", "2026-06-13T16:00:00Z"),
("LA daily 2:30am, an hour the clocks skip",
dict(repeat_unit="day", repeat_every=1, hour=2, minute=30, timezone=LA),
"2026-03-08T09:59:00Z", "2026-03-08T10:30:00Z"),
("LA daily 1:30am, first time through the repeated hour",
dict(repeat_unit="day", repeat_every=1, hour=1, minute=30, timezone=LA),
"2026-11-01T08:00:00Z", "2026-11-01T08:30:00Z"),
("LA daily 1:30am, asked at 1:45 the first time round, so today's slot has gone",
dict(repeat_unit="day", repeat_every=1, hour=1, minute=30, timezone=LA),
"2026-11-01T08:45:00Z", "2026-11-02T09:30:00Z"),
("LA daily 1:30am, asked at 1:15 the second time round, so it fires again this hour",
dict(repeat_unit="day", repeat_every=1, hour=1, minute=30, timezone=LA),
"2026-11-01T09:15:00Z", "2026-11-01T09:30:00Z"),
("LA daily 1:30am, asked at exactly 1:30, so this one has just gone",
dict(repeat_unit="day", repeat_every=1, hour=1, minute=30, timezone=LA),
"2026-11-01T08:30:00Z", "2026-11-02T09:30:00Z"),
("Berlin daily 2:30am, an hour the clocks skip",
dict(repeat_unit="day", repeat_every=1, hour=2, minute=30, timezone="Europe/Berlin"),
"2026-03-29T00:30:00Z", "2026-03-29T01:30:00Z"),
("Berlin daily 2:30am, the second time through the repeated hour",
dict(repeat_unit="day", repeat_every=1, hour=2, minute=30, timezone="Europe/Berlin"),
"2026-10-25T01:17:33Z", "2026-10-25T01:30:00Z"),
("Sydney Sundays 11:45pm, over their spring-forward",
dict(repeat_unit="week", repeat_every=1, on_days=[0], hour=23, minute=45, timezone="Australia/Sydney"),
"2026-10-02T05:00:00Z", "2026-10-04T12:45:00Z"),
("Kolkata daily 9am, a half-hour offset with no DST",
dict(repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="Asia/Kolkata"),
"2026-06-15T12:00:00Z", "2026-06-16T03:30:00Z"),
("UTC daily midnight, asked one second after it fired",
dict(repeat_unit="day", repeat_every=1, hour=0, minute=0, timezone="UTC"),
"2026-06-15T00:00:01Z", "2026-06-16T00:00:00Z"),
]
def p_moment(text: str) -> datetime:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
def p_workflow(config: dict) -> Workflow:
# created_at is the phase anchor for multi-period cadences. Every schedule here repeats once per
# period, so it cannot move an answer; it is pinned only so nothing about these rows floats.
return Workflow(
title="agreement",
created_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
schedule=ScheduleConfig(enabled=True, **config),
)
def test_the_local_scheduler_hits_every_vector():
for label, config, asked, fires in VECTORS:
assert compute_next_fire(p_workflow(config), p_moment(asked)) == p_moment(fires), label
def test_every_vector_maps_onto_a_schedule_the_cloud_can_hold():
"""A vector the cloud would refuse proves nothing about the two engines agreeing."""
for label, config, *_ in VECTORS:
mapping = to_cloud_schedule(ScheduleConfig(enabled=True, **config))
assert isinstance(mapping, ScheduleSupported), label
assert isinstance(mapping.schedule, (CloudDailySchedule, CloudWeeklySchedule)), label
# The zone has to survive the trip, or the cloud does its maths in the wrong one.
assert wire(mapping.schedule)["timezone"] == config["timezone"], label
def test_a_daily_nine_am_never_drifts_off_nine_am():
"""The reason the zone travels with the schedule at all: stored as a UTC hour, every one of
these fires would move by an hour twice a year for anyone outside UTC."""
wf = p_workflow(dict(repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone=LA))
cursor = datetime(2026, 3, 5, tzinfo=timezone.utc)
days = set()
for _ in range(250):
cursor = compute_next_fire(wf, cursor)
local = cursor.astimezone(ZoneInfo(LA))
assert (local.hour, local.minute) == (9, 0), cursor.isoformat()
days.add(local.date())
assert len(days) == 250, "one fire per calendar day, none doubled and none skipped"