sched: extract typed preparer/dispatcher seams; test impl out of production (F6)
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""Controlled scheduler collaborators for tests (fixture side of F6 seams).
|
||||
|
||||
``DictDeployments``, ``fixture_environment``, and ``ScriptedDispatcher``
|
||||
are the test doubles behind the production
|
||||
:mod:`wf_scheduling.prepare` / :mod:`wf_scheduling.dispatch` protocols.
|
||||
They carry the fixture behavior the old production ``Scheduler`` used to
|
||||
hardcode (fixture input, fixture environments, scripted outcomes); the
|
||||
production package no longer contains any of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Union
|
||||
|
||||
from tests.artifacts.test_run_store import artifact as _artifact
|
||||
from tests.artifacts.test_run_store import deployment as _deployment
|
||||
from wf_artifacts.runs.models import PinnedRunEnvironment, RunAdmission
|
||||
from wf_core import RunState, RunStatus
|
||||
from wf_scheduling.dispatch import DispatchResult, StillRunning, Stopped
|
||||
|
||||
OutcomeSpec = Union[str, Callable[[RunAdmission, datetime], DispatchResult]]
|
||||
|
||||
|
||||
class DictDeployments:
|
||||
"""Deployment directory over a plain ``{id: {"rev": n, "required": [...]}}`` map."""
|
||||
|
||||
def __init__(self, deployments: dict[str, dict[str, Any]]) -> None:
|
||||
self._deployments = deployments
|
||||
|
||||
def deployment_revision(self, deployment_id: str) -> int:
|
||||
return int(self._deployments[deployment_id]["rev"])
|
||||
|
||||
def required_inputs(self, deployment_id: str) -> Sequence[str]:
|
||||
return list(self._deployments[deployment_id].get("required", []))
|
||||
|
||||
|
||||
def fixture_environment(sched: Any) -> PinnedRunEnvironment:
|
||||
"""Build the historic fixture environment for schedule tests."""
|
||||
deployment = _deployment().model_copy(
|
||||
update={"id": getattr(sched, "deployment_id", "dep-1")}
|
||||
)
|
||||
return PinnedRunEnvironment(
|
||||
deployment=deployment, root_artifact=_artifact(), child_artifacts=[]
|
||||
)
|
||||
|
||||
|
||||
def stopped_state(admission: RunAdmission, outcome: str) -> RunState:
|
||||
"""Build a genuine stopped RunState echoing the admitted input."""
|
||||
status = {
|
||||
"complete": RunStatus.COMPLETED,
|
||||
"interrupt": RunStatus.INTERRUPTED,
|
||||
"fail": RunStatus.FAILED,
|
||||
}[outcome]
|
||||
return RunState(
|
||||
workflow_name="sched",
|
||||
status=status,
|
||||
workflow_input=dict(admission.resolved_input),
|
||||
state={},
|
||||
)
|
||||
|
||||
|
||||
class ScriptedDispatcher:
|
||||
"""Scripted execution double: ``{run_id | "*": outcome | callable}``.
|
||||
|
||||
Outcomes are ``complete`` | ``interrupt`` | ``fail`` | ``hang``.
|
||||
Callables receive ``(admission, now)`` and may raise or terminate the
|
||||
process (crash tests). ``finish`` builds a stopped state for a hanging
|
||||
run so tests can settle it through the scheduler's async-completion
|
||||
seam without re-dispatching.
|
||||
"""
|
||||
|
||||
def __init__(self, script: dict[str, OutcomeSpec] | None = None) -> None:
|
||||
self.script: dict[str, OutcomeSpec] = dict(script or {})
|
||||
|
||||
def dispatch(self, *, admission: RunAdmission, now: datetime) -> DispatchResult:
|
||||
spec = self.script.get(admission.id, self.script.get("*", "complete"))
|
||||
if callable(spec):
|
||||
return spec(admission, now)
|
||||
assert isinstance(spec, str), f"unknown scripted outcome {spec!r}"
|
||||
if spec == "hang":
|
||||
return StillRunning()
|
||||
if spec in ("complete", "interrupt", "fail"):
|
||||
return Stopped(result=stopped_state(admission, spec))
|
||||
raise ValueError(f"unknown scripted outcome {spec!r} for {admission.id!r}")
|
||||
|
||||
def finish(self, admission: RunAdmission, outcome: str) -> RunState:
|
||||
"""Build the stopped state that settles a hanging run."""
|
||||
return stopped_state(admission, outcome)
|
||||
@@ -4,12 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from tests.scheduling.controlled import (
|
||||
DictDeployments,
|
||||
ScriptedDispatcher,
|
||||
fixture_environment,
|
||||
)
|
||||
from wf_artifacts.runs.models import StoredRunStatus
|
||||
from wf_artifacts.runs.store import FileRunStore
|
||||
from wf_scheduling.calendar import OneShotSource
|
||||
from wf_scheduling.models import Schedule
|
||||
from wf_scheduling.poll import SCAN_CAP, Scheduler
|
||||
from wf_scheduling.prepare import SchedulePreparer
|
||||
from wf_scheduling.store import FileScheduleStore
|
||||
|
||||
UTC_TZ = UTC
|
||||
@@ -41,21 +48,6 @@ class PeriodicSource:
|
||||
return self.start + n * self.period
|
||||
|
||||
|
||||
class OneShotSource:
|
||||
def __init__(self, at: datetime) -> None:
|
||||
self.at = at
|
||||
self.next_calls = 0
|
||||
self.prev_calls = 0
|
||||
|
||||
def next_after(self, instant: datetime) -> datetime | None:
|
||||
self.next_calls += 1
|
||||
return self.at if instant < self.at else None
|
||||
|
||||
def prev_before(self, instant: datetime) -> datetime | None:
|
||||
self.prev_calls += 1
|
||||
return self.at if instant > self.at else None
|
||||
|
||||
|
||||
def _sched_model(sid: str, start_hint: str = "cron", **kw: Any) -> Schedule:
|
||||
now = ts(2026, 9, 8, 12, 0)
|
||||
base: dict[str, Any] = {
|
||||
@@ -74,21 +66,27 @@ def _harness(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
capacity: int = 4,
|
||||
outcomes: dict[str, str] | None = None,
|
||||
script: dict | None = None,
|
||||
deployments: dict[str, dict] | None = None,
|
||||
) -> tuple[Scheduler, FileScheduleStore, FileRunStore, dict]:
|
||||
sched_store = FileScheduleStore(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
sources: dict = {}
|
||||
preparer = SchedulePreparer(
|
||||
DictDeployments(
|
||||
deployments
|
||||
if deployments is not None
|
||||
else {"dep-1": {"rev": 1, "required": []}}
|
||||
),
|
||||
fixture_environment,
|
||||
)
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources=sources,
|
||||
capacity=capacity,
|
||||
outcomes=outcomes,
|
||||
deployments=deployments
|
||||
if deployments is not None
|
||||
else {"dep-1": {"rev": 1, "required": []}},
|
||||
preparer=preparer,
|
||||
dispatcher=ScriptedDispatcher(script),
|
||||
)
|
||||
return sched, sched_store, run_store, sources
|
||||
|
||||
@@ -119,7 +117,7 @@ def test_overlap_skip_blocks_and_late_drops() -> None:
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sched, store, runs, sources = _harness(root, capacity=4, outcomes={"*": "hang"})
|
||||
sched, store, runs, sources = _harness(root, capacity=4, script={"*": "hang"})
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
_add(
|
||||
sched,
|
||||
@@ -161,7 +159,7 @@ def test_latest_coalesces_to_one_candidate_and_no_double_admit() -> None:
|
||||
# Capacity returns at 13:00 while 13:00 is also due: exactly one
|
||||
# admission for 13:00, 12:00 superseded, never both.
|
||||
sched.capacity = 4
|
||||
sched.outcomes = {"*": "complete"}
|
||||
cast(ScriptedDispatcher, sched.dispatcher).script = {"*": "complete"}
|
||||
sched.poll(ts(2026, 9, 8, 13, 0))
|
||||
admitted = [r for r in _history(store, "h") if r["kind"] == "admitted"]
|
||||
assert len(admitted) == 1
|
||||
@@ -176,7 +174,7 @@ def test_parallel_limits_and_interrupted_slots() -> None:
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sched, store, runs, sources = _harness(root, capacity=4, outcomes={"*": "hang"})
|
||||
sched, store, runs, sources = _harness(root, capacity=4, script={"*": "hang"})
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
_add(
|
||||
sched,
|
||||
@@ -216,7 +214,7 @@ def test_pause_is_not_downtime() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sched, store, runs, sources = _harness(
|
||||
root, capacity=4, outcomes={"*": "complete"}
|
||||
root, capacity=4, script={"*": "complete"}
|
||||
)
|
||||
_add(
|
||||
sched,
|
||||
@@ -244,7 +242,7 @@ def test_pause_is_not_downtime() -> None:
|
||||
|
||||
def test_long_downtime_is_bounded(tmp_path: Path) -> None:
|
||||
sched, store, runs, sources = _harness(
|
||||
tmp_path, capacity=4, outcomes={"*": "complete"}
|
||||
tmp_path, capacity=4, script={"*": "complete"}
|
||||
)
|
||||
src = PeriodicSource(timedelta(minutes=1), ts(2023, 9, 8, 12, 0))
|
||||
_add(sched, store, sources, "m", src, ts(2023, 9, 8, 12, 0), misfire="latest")
|
||||
@@ -259,7 +257,7 @@ def test_long_downtime_is_bounded(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_fairness_slow_schedule_not_starved(tmp_path: Path) -> None:
|
||||
sched, store, runs, sources = _harness(tmp_path, capacity=1, outcomes={"*": "hang"})
|
||||
sched, store, runs, sources = _harness(tmp_path, capacity=1, script={"*": "hang"})
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
_add(
|
||||
sched,
|
||||
@@ -288,6 +286,6 @@ def test_capacity_wait_then_expire_for_skip(tmp_path: Path) -> None:
|
||||
assert sched.poll(t0) == {"a": "admit:held-undecided"}
|
||||
assert store.get_candidate("a") is None
|
||||
sched.capacity = 1
|
||||
sched.outcomes = {"*": "complete"}
|
||||
cast(ScriptedDispatcher, sched.dispatcher).script = {"*": "complete"}
|
||||
sched.poll(t0 + timedelta(seconds=30))
|
||||
assert len([r for r in _history(store, "a") if r["kind"] == "admitted"]) == 1
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Typed invocation preparation over real schedule bindings (R4/F6).
|
||||
|
||||
The scheduler no longer hardcodes fixture input: ``SchedulePreparer``
|
||||
resolves the schedule's own ``input_bindings`` against the occurrence
|
||||
environment through the shared schedule-expression contract, rechecks
|
||||
the deployment directory, and reports preflight rejections without
|
||||
inventing a run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from tests.scheduling.controlled import (
|
||||
DictDeployments,
|
||||
ScriptedDispatcher,
|
||||
fixture_environment,
|
||||
)
|
||||
from wf_artifacts.runs.store import FileRunStore
|
||||
from wf_scheduling.calendar import OneShotSource
|
||||
from wf_scheduling.dispatch import RunDispatcher
|
||||
from wf_scheduling.models import Schedule
|
||||
from wf_scheduling.poll import Scheduler
|
||||
from wf_scheduling.prepare import (
|
||||
InvocationPreparer,
|
||||
PreparationRejected,
|
||||
SchedulePreparer,
|
||||
)
|
||||
from wf_scheduling.store import FileScheduleStore
|
||||
|
||||
|
||||
def ts(y: int, mo: int, d: int, h: int = 0, mi: int = 0) -> datetime:
|
||||
return datetime(y, mo, d, h, mi, tzinfo=UTC)
|
||||
|
||||
|
||||
def _sched_model(sid: str, **kw: Any) -> Schedule:
|
||||
now = ts(2026, 9, 8, 12, 0)
|
||||
base: dict[str, Any] = {
|
||||
"id": sid,
|
||||
"deployment_id": "dep-1",
|
||||
"trigger": {"kind": "cron", "expression": "0 * * * *", "timezone": "UTC"},
|
||||
"input_bindings": [],
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
base.update(kw)
|
||||
return Schedule.model_validate(base)
|
||||
|
||||
|
||||
def _scheduler(
|
||||
tmp_path: Path, script: dict | None = None
|
||||
) -> tuple[Scheduler, FileScheduleStore, FileRunStore]:
|
||||
sched_store = FileScheduleStore(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
preparer = SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 3, "required": []}}),
|
||||
fixture_environment,
|
||||
)
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources={},
|
||||
capacity=4,
|
||||
preparer=preparer,
|
||||
dispatcher=ScriptedDispatcher(script),
|
||||
)
|
||||
return sched, sched_store, run_store
|
||||
|
||||
|
||||
def test_scheduler_requires_typed_collaborators() -> None:
|
||||
params = inspect.signature(Scheduler.__init__).parameters
|
||||
assert set(params) == {
|
||||
"self",
|
||||
"schedule_store",
|
||||
"run_store",
|
||||
"sources",
|
||||
"capacity",
|
||||
"preparer",
|
||||
"dispatcher",
|
||||
}
|
||||
|
||||
|
||||
def test_production_scheduler_has_no_fixture_seams() -> None:
|
||||
import wf_scheduling.poll as poll_module
|
||||
|
||||
source = inspect.getsource(poll_module)
|
||||
assert "_test_environment" not in source
|
||||
assert "self.outcomes" not in source
|
||||
assert "self.deployments" not in source
|
||||
assert "FixturePreparer" not in source
|
||||
assert '"eng"' not in source and "'eng'" not in source
|
||||
# Collaborators are protocols consumed here, implemented elsewhere.
|
||||
assert "InvocationPreparer" in source
|
||||
assert "RunDispatcher" in source
|
||||
assert isinstance(
|
||||
poll_module.Scheduler.__init__.__annotations__["preparer"], object
|
||||
)
|
||||
|
||||
|
||||
def test_preparer_contract_is_a_protocol() -> None:
|
||||
assert issubclass(InvocationPreparer, object)
|
||||
assert issubclass(RunDispatcher, object)
|
||||
|
||||
|
||||
def test_occurrence_bindings_resolve_into_admission(tmp_path: Path) -> None:
|
||||
sched, store, runs = _scheduler(tmp_path, script={"*": "hang"})
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
model = _sched_model(
|
||||
"b",
|
||||
input_bindings=[
|
||||
{
|
||||
"target": "team",
|
||||
"expression": {"kind": "literal", "value": "engineering"},
|
||||
},
|
||||
{
|
||||
"target": "report_time",
|
||||
"expression": {"kind": "occurrence", "field": "scheduled_at"},
|
||||
},
|
||||
{
|
||||
"target": "which",
|
||||
"expression": {"kind": "occurrence", "field": "schedule_id"},
|
||||
},
|
||||
],
|
||||
)
|
||||
store.create_schedule(model)
|
||||
store.save_consumed("b", intended - timedelta(hours=1))
|
||||
sched.sources["b"] = OneShotSource(intended)
|
||||
result = sched.poll(intended)
|
||||
assert result["b"].startswith("admit:run-")
|
||||
run_id = result["b"].split(":", 1)[1]
|
||||
admission = runs.get_admission(run_id)
|
||||
assert admission.resolved_input["team"] == "engineering"
|
||||
assert admission.resolved_input["report_time"] == intended.isoformat()
|
||||
assert admission.resolved_input["which"] == "b"
|
||||
assert admission.deployment_revision == 3
|
||||
assert admission.schedule_revision == 1
|
||||
|
||||
|
||||
def test_unknown_deployment_rejects_without_a_run(tmp_path: Path) -> None:
|
||||
sched, store, runs = _scheduler(tmp_path)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
store.create_schedule(_sched_model("gone", deployment_id="dep-missing"))
|
||||
store.save_consumed("gone", intended - timedelta(hours=1))
|
||||
sched.sources["gone"] = OneShotSource(intended)
|
||||
assert sched.poll(intended) == {"gone": "admit:None"}
|
||||
assert runs.list_runs() == []
|
||||
assert runs.list_admissions() == []
|
||||
page = store.list_occurrences("gone", limit=100)
|
||||
kinds = [r["kind"] for r in cast(list[dict[str, Any]], page["occurrences"])]
|
||||
assert kinds == ["preflight-rejected"]
|
||||
|
||||
|
||||
def test_missing_required_input_rejects_without_a_run(tmp_path: Path) -> None:
|
||||
sched_store = FileScheduleStore(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
preparer = SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 1, "required": ["token"]}}),
|
||||
fixture_environment,
|
||||
)
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources={},
|
||||
capacity=4,
|
||||
preparer=preparer,
|
||||
dispatcher=ScriptedDispatcher({"*": "hang"}),
|
||||
)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
sched_store.create_schedule(_sched_model("need"))
|
||||
sched_store.save_consumed("need", intended - timedelta(hours=1))
|
||||
sched.sources["need"] = OneShotSource(intended)
|
||||
assert sched.poll(intended) == {"need": "admit:None"}
|
||||
assert run_store.list_runs() == []
|
||||
page = sched_store.list_occurrences("need", limit=100)
|
||||
entry = cast(list[dict[str, Any]], page["occurrences"])[0]
|
||||
assert entry["kind"] == "preflight-rejected"
|
||||
assert "missing-input" in entry["reason"]
|
||||
|
||||
|
||||
def test_conflicting_schedule_targets_reject_without_a_run(tmp_path: Path) -> None:
|
||||
sched, store, runs = _scheduler(tmp_path)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
store.create_schedule(
|
||||
_sched_model(
|
||||
"conflict",
|
||||
input_bindings=[
|
||||
{
|
||||
"target": "a",
|
||||
"expression": {"kind": "literal", "value": 1},
|
||||
},
|
||||
{
|
||||
"target": "a.b",
|
||||
"expression": {"kind": "literal", "value": 2},
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
store.save_consumed("conflict", intended - timedelta(hours=1))
|
||||
sched.sources["conflict"] = OneShotSource(intended)
|
||||
assert sched.poll(intended) == {"conflict": "admit:None"}
|
||||
assert runs.list_runs() == []
|
||||
page = store.list_occurrences("conflict", limit=100)
|
||||
entry = cast(list[dict[str, Any]], page["occurrences"])[0]
|
||||
assert entry["kind"] == "preflight-rejected"
|
||||
assert entry["reason"].startswith("invalid-input:")
|
||||
|
||||
|
||||
def test_preparer_rejection_type_shape() -> None:
|
||||
import dataclasses
|
||||
|
||||
rejected = PreparationRejected(reason="deployment-deleted")
|
||||
assert rejected.reason == "deployment-deleted"
|
||||
assert dataclasses.is_dataclass(PreparationRejected)
|
||||
@@ -6,10 +6,17 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from tests.scheduling.controlled import (
|
||||
DictDeployments,
|
||||
ScriptedDispatcher,
|
||||
fixture_environment,
|
||||
)
|
||||
from wf_artifacts.runs.store import FileRunStore
|
||||
from wf_scheduling import recovery as sched_recovery
|
||||
from wf_scheduling.calendar import OneShotSource
|
||||
from wf_scheduling.models import Schedule
|
||||
from wf_scheduling.poll import Scheduler
|
||||
from wf_scheduling.prepare import SchedulePreparer
|
||||
from wf_scheduling.store import FileScheduleStore
|
||||
|
||||
|
||||
@@ -92,7 +99,6 @@ def test_recovery_never_executes_pending_until_poll(tmp_path: Path) -> None:
|
||||
|
||||
from tests.artifacts.test_run_store import artifact as _artifact
|
||||
from tests.artifacts.test_run_store import deployment as _deployment
|
||||
from tests.scheduling.test_poll import OneShotSource
|
||||
from wf_api.run_lifecycle import persist_admission
|
||||
from wf_artifacts import PinnedRunEnvironment
|
||||
|
||||
@@ -124,7 +130,11 @@ def test_recovery_never_executes_pending_until_poll(tmp_path: Path) -> None:
|
||||
run_store=run_store,
|
||||
sources=sources, # type: ignore[arg-type]
|
||||
capacity=4,
|
||||
outcomes={"*": "complete"},
|
||||
preparer=SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 1, "required": []}}),
|
||||
fixture_environment,
|
||||
),
|
||||
dispatcher=ScriptedDispatcher({"*": "complete"}),
|
||||
)
|
||||
sched_store.save_consumed("a", ts(2026, 9, 8, 12, 0))
|
||||
sched.poll(ts(2026, 9, 8, 12, 1))
|
||||
|
||||
Reference in New Issue
Block a user