sched: require proven ownership at poll/admin/recovery entries (F5)

This commit is contained in:
lda
2026-09-08 11:24:40 +07:00 Verified
parent 15bd472bdc
commit 993ed07fd3
7 changed files with 293 additions and 22 deletions
+18
View File
@@ -27,6 +27,7 @@ from wf_scheduling.calendar import (
from wf_scheduling.dispatch import RunDispatcher, StillRunning
from wf_scheduling.models import OccurrenceRecord, PendingCandidate
from wf_scheduling.occurrences import occurrence_id
from wf_scheduling.ownership import SchedulerOwnership, SecondOwnerError
from wf_scheduling.prepare import InvocationPreparer, PreparationRejected
UTC = timezone.utc
@@ -84,6 +85,7 @@ class Scheduler:
capacity: int,
preparer: InvocationPreparer,
dispatcher: RunDispatcher,
ownership: SchedulerOwnership,
) -> None:
self.schedule_store = schedule_store
self.run_store = run_store
@@ -91,8 +93,21 @@ class Scheduler:
self.capacity = capacity
self.preparer = preparer
self.dispatcher = dispatcher
self.ownership = ownership
self._poll_cursor = 0
def _require_ownership(self) -> None:
"""Reject schedule mutation/dispatch without proven live ownership.
Runs before any store write or dispatcher side effect: without a
held lock this process cannot prove exclusive ownership, so polling
or administering schedules would risk double admission.
"""
if self.ownership is None or not self.ownership.held:
raise SecondOwnerError(
"scheduler ownership is required before polling or mutating schedules"
)
# -- helpers ------------------------------------------------------
@staticmethod
def _status_value(run: Any) -> Any:
@@ -342,6 +357,7 @@ class Scheduler:
# -- polling --------------------------------------------------------
def poll(self, now: datetime) -> dict[str, str]:
self._require_ownership()
self._dispatch_pending(now)
schedules = self.schedule_store.list_schedules(include_deleted=True)
ids = sorted(item.id for item in schedules)
@@ -616,6 +632,7 @@ class Scheduler:
# -- administration ---------------------------------------------------
def resume_schedule(self, sid: str, now: datetime) -> None:
"""Unpause: resume selects the next future occurrence."""
self._require_ownership()
sched = self.schedule_store.get_schedule(sid)
sched.paused = False
self.schedule_store.save_schedule(sched)
@@ -625,6 +642,7 @@ class Scheduler:
def edit_schedule(self, sid: str, now: datetime) -> None:
"""Definition edit: new revision, discard old candidates, no backfill."""
self._require_ownership()
sched = self.schedule_store.get_schedule(sid)
sched.revision += 1
sched.updated_at = now