sched: extract typed preparer/dispatcher seams; test impl out of production (F6)

This commit is contained in:
lda
2026-09-08 11:19:48 +07:00 Verified
parent 7f6c832b0b
commit 15bd472bdc
8 changed files with 564 additions and 162 deletions
+71 -123
View File
@@ -2,20 +2,32 @@
Mirrors the reference state model (probes/deployment_scheduling_verify/
test_schedule_state_model.py) against real file stores. Calendar iteration
uses ``OccurrenceSource`` (``next_after``/``prev_before`` only, never
enumeration); latest-missed catch-up is one bounded ``prev_before`` query
(F1). Overlap decisions precede capacity checks; terminal skips never
reappear; ``latest`` retains at most one candidate.
uses the canonical :class:`wf_scheduling.calendar.OccurrenceSource`
(``next_after``/``prev_before`` only, never enumeration); latest-missed
catch-up is one bounded ``prev_before`` query (F1). Overlap decisions
precede capacity checks; terminal skips never reappear; ``latest`` retains
at most one candidate.
Admission preparation and execution arrive as typed collaborators
(:mod:`wf_scheduling.prepare`, :mod:`wf_scheduling.dispatch`): this module
contains no fixture input, fixture environments, or canned results.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Protocol
from typing import Any
from wf_scheduling.calendar import CronSource, OneShotSource
from wf_scheduling.calendar import (
CronSource,
InvalidScheduleDefinitionError,
OccurrenceSource,
OneShotSource,
)
from wf_scheduling.dispatch import RunDispatcher, StillRunning
from wf_scheduling.models import OccurrenceRecord, PendingCandidate
from wf_scheduling.occurrences import occurrence_id
from wf_scheduling.prepare import InvocationPreparer, PreparationRejected
UTC = timezone.utc
SCAN_CAP = 100
@@ -26,17 +38,6 @@ class BlockedSchedule(ValueError):
"""A schedule is blocked by a corrupt record and fails closed."""
class InvalidScheduleDefinitionError(ValueError):
"""A schedule definition or its trigger source is invalid."""
class OccurrenceSource(Protocol):
"""Due-instant source over aware datetimes, UTC at the boundary."""
def next_after(self, instant: datetime) -> datetime | None: ...
def prev_before(self, instant: datetime) -> datetime | None: ...
def source_for_trigger(trigger: Any) -> OccurrenceSource:
"""Build a calendar source from a schedule trigger model."""
kind = trigger.kind if hasattr(trigger, "kind") else trigger.get("kind")
@@ -61,22 +62,17 @@ def source_for_trigger(trigger: Any) -> OccurrenceSource:
def _is_oneshot(source: OccurrenceSource) -> bool:
# Calendar and test-double one-shots share the class name and an ``at``
# instant; periodic sources carry a ``period`` instead. isinstance covers
# the calendar type; the name check covers duck-typed test doubles.
if isinstance(source, OneShotSource):
return True
return type(source).__name__ == "OneShotSource" and hasattr(source, "at")
return isinstance(source, OneShotSource)
class Scheduler:
"""File-store scheduler core with injected clock and controlled dispatch.
"""File-store scheduler core with injected clock and collaborators.
``outcomes`` maps run ids (or ``"*"``) to ``complete`` | ``hang`` |
``interrupt`` for deterministic tests. Production dispatch (real runtime)
is wired in T12; here ``hang`` keeps an admitted run occupying its
schedule slot, ``interrupt`` marks it interrupted (schedule slot only),
and ``complete`` marks it completed (releases overlap).
Invocation preparation (:class:`wf_scheduling.prepare.InvocationPreparer`)
and execution (:class:`wf_scheduling.dispatch.RunDispatcher`) are typed
collaborators: production code here never fabricates input, environments,
or outcomes. Stopped results are persisted through the shared
``wf_api.run_lifecycle`` boundary.
"""
def __init__(
@@ -86,15 +82,15 @@ class Scheduler:
run_store: Any,
sources: dict[str, OccurrenceSource],
capacity: int,
outcomes: dict[str, str] | None = None,
deployments: dict[str, dict] | None = None,
preparer: InvocationPreparer,
dispatcher: RunDispatcher,
) -> None:
self.schedule_store = schedule_store
self.run_store = run_store
self.sources = sources
self.capacity = capacity
self.outcomes = outcomes or {}
self.deployments = deployments or {"dep-1": {"rev": 1, "required": []}}
self.preparer = preparer
self.dispatcher = dispatcher
self._poll_cursor = 0
# -- helpers ------------------------------------------------------
@@ -191,13 +187,13 @@ class Scheduler:
sched.blocked_reason = reason
self.schedule_store.save_schedule(sched)
raise BlockedSchedule(reason) from None
dep = self.deployments.get(sched.deployment_id)
if dep is None:
prepared = self.preparer.prepare(sched=sched, intended=intended, now=now)
if isinstance(prepared, PreparationRejected):
self._record(
kind="preflight-rejected",
sched_id=sched.id,
intended=intended,
reason="deployment-deleted",
reason=prepared.reason,
revision=sched.revision,
now=now,
)
@@ -206,26 +202,6 @@ class Scheduler:
self.schedule_store.save_candidate(None, schedule_id=sched.id)
self.schedule_store.save_consumed(sched.id, intended)
return None
frozen = {
"team": "eng",
"report_time": intended.isoformat(),
"sched": sched.id,
"dep_rev": dep["rev"],
}
missing = [k for k in dep.get("required", []) if k not in frozen]
if missing:
self._record(
kind="preflight-rejected",
sched_id=sched.id,
intended=intended,
reason=f"missing-input:{missing}",
revision=sched.revision,
)
cand = self.schedule_store.get_candidate(sched.id)
if cand is not None and cand.intended_at == intended:
self.schedule_store.save_candidate(None, schedule_id=sched.id)
self.schedule_store.save_consumed(sched.id, intended)
return None
active = self._active(sched.id)
if sched.overlap == "skip" and active:
self._record(
@@ -277,20 +253,19 @@ class Scheduler:
return None
return "held-undecided"
run_id = self.run_store.allocate_run_id()
from wf_artifacts.runs.models import RunAdmission
from wf_api.run_lifecycle import persist_admission
admission = RunAdmission(
id=run_id,
environment=_test_environment(sched),
resolved_input=dict(frozen),
max_steps=getattr(sched, "max_steps", None),
admission = persist_admission(
store=self.run_store,
run_id=run_id,
environment=prepared.environment,
resolved_input=prepared.resolved_input,
max_steps=prepared.max_steps,
scheduled_at=intended,
schedule_id=sched.id,
schedule_revision=sched.revision,
deployment_revision=dep["rev"],
created_at=now,
deployment_revision=prepared.deployment_revision,
)
self.run_store.save_admission(admission)
self._record(
kind="admitted",
sched_id=sched.id,
@@ -313,56 +288,52 @@ class Scheduler:
# Dispatch mark precedes the view so a crash after admission but
# before/during first dispatch stays pending (not abandoned). Cleared
# after dispatch returns regardless of outcome (hang still dispatched).
try:
self.run_store.mark_pending_dispatch(run_id)
except AttributeError:
pass
self.run_store.mark_pending_dispatch(run_id)
materialize_admitted_view(store=self.run_store, admission=admission)
try:
self._dispatch(run_id, now)
finally:
try:
self.run_store.clear_pending_dispatch(run_id)
except AttributeError:
pass
self.run_store.clear_pending_dispatch(run_id)
return run_id
def _dispatch(self, run_id: str, now: datetime) -> None:
"""Persist one stopped result for an admitted run via the dispatcher.
The dispatcher returns a genuine stopped :class:`wf_core.RunState`
(or :class:`StillRunning` when the outcome is unknown); persistence
goes through the shared ``wf_api.run_lifecycle`` boundary so
scheduler dispatches share the manual-run torn-write protocol.
"""
from wf_api.run_lifecycle import persist_stopped_run
from wf_artifacts.runs.models import StoredRunStatus
outcome = self.outcomes.get(run_id, self.outcomes.get("*", "complete"))
if outcome not in ("complete", "hang", "interrupt"):
raise ValueError(f"unknown controlled outcome {outcome!r} for {run_id!r}")
try:
record = self.run_store.get_run(run_id)
self.run_store.get_run(run_id)
except KeyError as exc:
raise BlockedSchedule(f"dispatch missing run view: {run_id!r}") from exc
sched_id = self._run_sched(record)
if sched_id is None:
raise BlockedSchedule(f"dispatch missing admission: {run_id!r}")
if outcome == "hang":
try:
admission = self.run_store.get_admission(run_id)
except KeyError as exc:
raise BlockedSchedule(f"dispatch missing admission: {run_id!r}") from exc
if admission.schedule_id is None:
raise BlockedSchedule(f"dispatch missing schedule owner: {run_id!r}")
result = self.dispatcher.dispatch(admission=admission, now=now)
if isinstance(result, StillRunning):
return
if outcome == "interrupt":
updated = record.model_copy(
update={"status": StoredRunStatus.INTERRUPTED, "updated_at": now}
)
self.run_store.save_run(updated)
self._record(
kind="interrupted",
sched_id=sched_id,
intended=_admission_intended(self.run_store, run_id),
run_id=run_id,
now=now,
started_at=now,
)
return
updated = record.model_copy(
update={"status": StoredRunStatus.COMPLETED, "updated_at": now}
stopped = persist_stopped_run(
store=self.run_store,
environment=admission.environment,
run=result.result,
run_id=run_id,
)
self.run_store.save_run(updated)
kind = {
StoredRunStatus.COMPLETED: "completed",
StoredRunStatus.INTERRUPTED: "interrupted",
StoredRunStatus.FAILED: "failed",
}[stopped.status]
self._record(
kind="completed",
sched_id=sched_id,
kind=kind, # type: ignore[arg-type]
sched_id=admission.schedule_id,
intended=_admission_intended(self.run_store, run_id),
run_id=run_id,
now=now,
@@ -672,29 +643,6 @@ class Scheduler:
self.schedule_store.save_consumed(sid, max(consumed, now))
def _test_environment(sched: Any) -> Any:
from wf_artifacts import PinnedRunEnvironment, WorkflowArtifact, WorkflowDeployment
deployment = WorkflowDeployment(
id=getattr(sched, "deployment_id", "dep-1"),
artifact_id="wf-1",
artifact_version=1,
bindings=[],
)
artifact = WorkflowArtifact(
id="wf-1",
version=1,
title="Wf-1",
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
outcomes=("ok",),
plan={"name": "wf-1", "nodes": [], "edges": []},
)
return PinnedRunEnvironment(
deployment=deployment, root_artifact=artifact, child_artifacts=[]
)
def _admission_intended(run_store: Any, run_id: str) -> datetime | None:
try:
return run_store.get_admission(run_id).scheduled_at