sched: crash-safe admin ordering, create watermark, poll freshness (T13 fix)

This commit is contained in:
lda
2026-09-09 12:06:37 +07:00 Verified
parent 2c84aba96b
commit e09c08899a
6 changed files with 255 additions and 31 deletions
+40 -9
View File
@@ -582,6 +582,22 @@ class Scheduler:
self._execute_guarded(run.id, now)
def _poll_one(self, sched: Any, now: datetime) -> str:
# Fresh read per schedule: the tick lists schedules up front while
# same-process administration may commit an edit, pause, resume, or
# delete mid-tick. All admission decisions below (policies,
# revision, flags) use this fresh copy, never the listing
# snapshot. Two narrow residuals remain: calendar iteration uses
# the tick-start occurrence source (a trigger edit takes effect
# on the next tick), and an admission decision already in flight
# cannot observe a concurrent edit — admission stays idempotent
# per (schedule, instant), so the worst case is one run admitted
# under just-superseded terms, never a duplicate or replay.
try:
sched = self.schedule_store.get_schedule(sched.id)
except KeyError:
# Soft deletes never remove the file; a vanishing schedule is
# unexpectedly gone — treat it as deleted work, never admit.
return "deleted"
if sched.deleted:
if self.schedule_store.get_candidate(sched.id) is not None:
self.schedule_store.save_candidate(None, schedule_id=sched.id)
@@ -806,22 +822,33 @@ class Scheduler:
# -- administration ---------------------------------------------------
def resume_schedule(self, sid: str, now: datetime) -> None:
"""Unpause: resume selects the next future occurrence."""
"""Unpause: resume selects the next future occurrence.
Crash-safe ordering shared with the API surface: the candidate
is cleared and the watermark advances BEFORE the flag flip is
persisted, so a crash can only leave the schedule paused
(retryable) and never an unpaused flag whose span backfills.
"""
self._require_ownership()
self.schedule_store.get_schedule(sid)
self.schedule_store.save_candidate(None, schedule_id=sid)
consumed = self.schedule_store.get_consumed(sid) or EPOCH
self.schedule_store.save_consumed(sid, max(consumed, now))
sched = self.schedule_store.get_schedule(sid)
sched.paused = False
self.schedule_store.save_schedule(sched)
consumed = self.schedule_store.get_consumed(sid) or EPOCH
self.schedule_store.save_consumed(sid, max(consumed, now))
self.schedule_store.save_candidate(None, schedule_id=sid)
def edit_schedule(self, sid: str, now: datetime) -> None:
"""Definition edit: new revision, discard old candidates, no backfill."""
"""Definition edit: new revision, discard old candidates, no backfill.
Crash-safe ordering shared with the API surface: old candidates
are discarded (with a superseded row) and the watermark advances
BEFORE the revision bump is persisted, so a crash can only leave
the edit unapplied under the old revision (retryable) and never
a bumped revision that backfills pre-edit instants on restart.
"""
self._require_ownership()
sched = self.schedule_store.get_schedule(sid)
sched.revision += 1
sched.updated_at = now
self.schedule_store.save_schedule(sched)
old = self.schedule_store.get_candidate(sid)
if old is not None:
self._record(
@@ -829,11 +856,15 @@ class Scheduler:
sched_id=sid,
intended=old.intended_at,
reason="schedule-edit",
revision=sched.revision,
revision=sched.revision + 1,
)
self.schedule_store.save_candidate(None, schedule_id=sid)
consumed = self.schedule_store.get_consumed(sid) or EPOCH
self.schedule_store.save_consumed(sid, max(consumed, now))
sched = self.schedule_store.get_schedule(sid)
sched.revision += 1
sched.updated_at = now
self.schedule_store.save_schedule(sched)
def _admission_intended(run_store: Any, run_id: str) -> datetime | None: