sched: extract typed preparer/dispatcher seams; test impl out of production (F6)
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Typed dispatch collaborator seam (R4/F6).
|
||||
|
||||
The scheduler owns durable markers, stopped-result persistence, and
|
||||
history; the dispatcher owns execution only. It receives the admitted
|
||||
invocation and returns a genuine :class:`wf_core.RunState` (or
|
||||
:class:`StillRunning` when the outcome is not yet known). Stopped
|
||||
results are persisted by the scheduler through the shared
|
||||
``wf_api.run_lifecycle`` boundary, so scheduler dispatches and manual
|
||||
runs share one torn-write protocol. Controlled scripted dispatchers live
|
||||
in ``tests/scheduling/controlled.py``; production wiring arrives in T12.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol, Union
|
||||
|
||||
from wf_artifacts.runs.models import RunAdmission
|
||||
from wf_core import RunState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Stopped:
|
||||
"""Execution finished with a genuine stopped run state."""
|
||||
|
||||
result: RunState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StillRunning:
|
||||
"""Dispatched with an unknown outcome; the run stays admitted."""
|
||||
|
||||
|
||||
DispatchResult = Union[Stopped, StillRunning]
|
||||
|
||||
|
||||
class RunDispatcher(Protocol):
|
||||
"""Execution collaborator behind the durable dispatch transition."""
|
||||
|
||||
def dispatch(self, *, admission: RunAdmission, now: datetime) -> DispatchResult: ...
|
||||
+71
-123
@@ -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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Typed invocation preparation for schedule admission (R4/F6).
|
||||
|
||||
Production schedule-side admission decisions live here: resolving the
|
||||
schedule's real input bindings against the occurrence environment and
|
||||
rechecking the deployment contract. Only artifact-tree construction is
|
||||
injected (``build_environment``) until the administration surface (T13)
|
||||
supplies store-backed environment pinning; controlled fixture
|
||||
environments and deployment directories live in
|
||||
``tests/scheduling/controlled.py``, never here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from wf_artifacts.runs.models import PinnedRunEnvironment
|
||||
from wf_core.runtime.input_sources import resolve_schedule_input_bindings
|
||||
from wf_scheduling.occurrences import occurrence_id
|
||||
|
||||
|
||||
class DeploymentDirectory(Protocol):
|
||||
"""Deployment contract source for admission rechecks.
|
||||
|
||||
Implementations raise ``KeyError`` for unknown deployment ids, which
|
||||
preparation reports as a ``deployment-deleted`` preflight rejection.
|
||||
"""
|
||||
|
||||
def deployment_revision(self, deployment_id: str) -> int: ...
|
||||
def required_inputs(self, deployment_id: str) -> Sequence[str]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedInvocation:
|
||||
"""Frozen invocation captured at admission for exactly one occurrence."""
|
||||
|
||||
environment: PinnedRunEnvironment
|
||||
resolved_input: dict[str, Any]
|
||||
max_steps: int | None
|
||||
deployment_revision: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparationRejected:
|
||||
"""Preflight rejection: no run is invented for this occurrence."""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
class InvocationPreparer(Protocol):
|
||||
"""Schedule-side admission preparation (production or controlled)."""
|
||||
|
||||
def prepare(
|
||||
self, *, sched: Any, intended: datetime, now: datetime
|
||||
) -> PreparedInvocation | PreparationRejected: ...
|
||||
|
||||
|
||||
class SchedulePreparer:
|
||||
"""Production preparation over the schedule's real input bindings.
|
||||
|
||||
Occurrence references resolve through the shared schedule-expression
|
||||
contract (never graph paths); target conflicts, over-budget trees, and
|
||||
invalid resolved input fail closed as preflight rejections before any
|
||||
run identity is allocated.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deployments: DeploymentDirectory,
|
||||
build_environment: Callable[[Any], PinnedRunEnvironment],
|
||||
) -> None:
|
||||
self._deployments = deployments
|
||||
self._build_environment = build_environment
|
||||
|
||||
def prepare(
|
||||
self, *, sched: Any, intended: datetime, now: datetime
|
||||
) -> PreparedInvocation | PreparationRejected:
|
||||
try:
|
||||
revision = self._deployments.deployment_revision(sched.deployment_id)
|
||||
required = list(self._deployments.required_inputs(sched.deployment_id))
|
||||
except KeyError:
|
||||
return PreparationRejected(reason="deployment-deleted")
|
||||
occurrence = {
|
||||
"schedule_id": sched.id,
|
||||
"occurrence_id": occurrence_id(sched.id, intended),
|
||||
"scheduled_at": intended.isoformat(),
|
||||
}
|
||||
try:
|
||||
resolved = resolve_schedule_input_bindings(
|
||||
sched.input_bindings,
|
||||
occurrence=occurrence,
|
||||
label=f"schedule {sched.id}",
|
||||
)
|
||||
except Exception as exc:
|
||||
return PreparationRejected(reason=f"invalid-input:{exc}")
|
||||
missing = [key for key in required if key not in resolved]
|
||||
if missing:
|
||||
return PreparationRejected(reason=f"missing-input:{missing}")
|
||||
return PreparedInvocation(
|
||||
environment=self._build_environment(sched),
|
||||
resolved_input=dict(resolved),
|
||||
max_steps=getattr(sched, "max_steps", None),
|
||||
deployment_revision=revision,
|
||||
)
|
||||
@@ -171,16 +171,9 @@ def _mark_pending(run_store: Any, run_id: str) -> None:
|
||||
|
||||
|
||||
def _is_pending(run_store: Any, run_id: str) -> bool:
|
||||
try:
|
||||
return bool(run_store.is_pending_dispatch(run_id))
|
||||
except AttributeError:
|
||||
# Legacy stores without the pending protocol: treat as not pending.
|
||||
return False
|
||||
return bool(run_store.is_pending_dispatch(run_id))
|
||||
|
||||
|
||||
def clear_pending(run_store: Any, run_id: str) -> None:
|
||||
"""Clear the pending-dispatch marker after the poll sweep dispatches."""
|
||||
try:
|
||||
run_store.clear_pending_dispatch(run_id)
|
||||
except AttributeError:
|
||||
return
|
||||
run_store.clear_pending_dispatch(run_id)
|
||||
|
||||
Reference in New Issue
Block a user