mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 04:02:22 +02:00
122 lines
6.5 KiB
Python
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"
|