From 39993c9d5e44b53394c8ac72d70be3a22bbee6fd Mon Sep 17 00:00:00 2001 From: lda Date: Wed, 9 Sep 2026 18:39:00 +0700 Subject: [PATCH] fix: publish schedules after creation watermark --- src/wf_api/schedules.py | 17 +++++++++++++---- tests/wf_api/test_schedules.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/wf_api/schedules.py b/src/wf_api/schedules.py index 56fa8e2a..3be3a10e 100644 --- a/src/wf_api/schedules.py +++ b/src/wf_api/schedules.py @@ -21,7 +21,7 @@ from wf_scheduling.history import FileScheduleHistoryRecorder, HistoryEntry from wf_scheduling.models import OccurrenceRecord, Schedule from wf_scheduling.occurrences import occurrence_id from wf_scheduling.prepare import PreparationRejected, SchedulePreparer -from wf_scheduling.store import StaleScheduleRevisionError +from wf_scheduling.store import ScheduleExistsError, StaleScheduleRevisionError from .models import ( JsonProjector, @@ -207,11 +207,20 @@ class WorkflowScheduleApi: } ) self._validate_definition(schedule) - stored = store.create_schedule(schedule) # Creation never backfills time before the revision: the consumed - # watermark starts at creation so catch-up only ever covers - # downtime after this revision, never pre-creation instants. + # watermark starts at creation so catch-up only ever covers downtime + # after this revision, never pre-creation instants. Check the id + # before this write so a duplicate create cannot overwrite the + # existing schedule's watermark; publishing the schedule last keeps + # a failed watermark write invisible to the poller. + try: + store.get_schedule(schedule_id) + except KeyError: + pass + else: + raise ScheduleExistsError(f"schedule id already exists: {schedule_id!r}") store.save_consumed(schedule_id, now) + stored = store.create_schedule(schedule) return _PROJECT_SCHEDULE(stored.model_dump(mode="json")) async def get_schedule(self, *, schedule_id: str) -> ScheduleResult: diff --git a/tests/wf_api/test_schedules.py b/tests/wf_api/test_schedules.py index f251587f..104a3049 100644 --- a/tests/wf_api/test_schedules.py +++ b/tests/wf_api/test_schedules.py @@ -528,6 +528,26 @@ async def test_create_initializes_consumed_no_backfill(tmp_path: Path) -> None: assert consumed is not None and consumed >= created_at +async def test_create_watermark_failure_does_not_publish_schedule( + tmp_path: Path, +) -> None: + """A failed creation watermark cannot expose a backfillable schedule.""" + api, sched_store, _ = _fault_harness(tmp_path / "torn_create") + sched_store.armed = True + + with pytest.raises(OSError, match="injected consumed failure"): + await api.create_schedule( + schedule_id="s", + deployment_id="dep.personal", + trigger=_cron(), + misfire="latest", + ) + + with pytest.raises(KeyError): + sched_store.get_schedule("s") + assert sched_store.get_consumed("s") is None + + async def test_update_watermark_failure_leaves_revision(tmp_path: Path) -> None: api, sched_store, _ = _fault_harness(tmp_path / "torn_update")