242 lines
8.2 KiB
Python
242 lines
8.2 KiB
Python
"""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.ownership import SchedulerOwnership
|
|
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),
|
|
ownership=SchedulerOwnership(tmp_path, owner="test").acquire(),
|
|
)
|
|
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",
|
|
"ownership",
|
|
"history",
|
|
}
|
|
|
|
|
|
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
|
|
sched.ownership.release()
|
|
|
|
|
|
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"]
|
|
sched.ownership.release()
|
|
|
|
|
|
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"}),
|
|
ownership=SchedulerOwnership(tmp_path, owner="test").acquire(),
|
|
)
|
|
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"]
|
|
sched.ownership.release()
|
|
|
|
|
|
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:")
|
|
sched.ownership.release()
|
|
|
|
|
|
def test_preparer_rejection_type_shape() -> None:
|
|
import dataclasses
|
|
|
|
rejected = PreparationRejected(reason="deployment-deleted")
|
|
assert rejected.reason == "deployment-deleted"
|
|
assert dataclasses.is_dataclass(PreparationRejected)
|
|
|
|
|
|
def test_vanished_deployment_in_environment_build_is_preflight() -> None:
|
|
def boom(sched: Any) -> Any:
|
|
raise KeyError("dep-1")
|
|
|
|
preparer = SchedulePreparer(
|
|
DictDeployments({"dep-1": {"rev": 3, "required": []}}),
|
|
boom,
|
|
)
|
|
intended = ts(2026, 9, 8, 12, 0)
|
|
result = preparer.prepare(
|
|
sched=_sched_model("gone"), intended=intended, now=intended
|
|
)
|
|
assert isinstance(result, PreparationRejected)
|
|
assert result.reason == "deployment-deleted"
|