sched: bind ownership to store composition; stable recovery failure; schema-checked prepare; guarded settle (R4 wave 2)
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""Resolved input validates against the pinned workflow schema (R4 item 3).
|
||||
|
||||
Preparation checks more than required key names: the resolved object must
|
||||
satisfy the pinned root artifact's input schema (same snapshot that is
|
||||
captured in the admission) before any run identity is allocated. Invalid
|
||||
input rejects the occurrence without admission, run, or dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from tests.artifacts.test_run_store import artifact as _artifact
|
||||
from tests.artifacts.test_run_store import deployment as _deployment
|
||||
from tests.scheduling.controlled import DictDeployments, ScriptedDispatcher
|
||||
from wf_artifacts.runs.models import PinnedRunEnvironment
|
||||
from wf_artifacts.runs.store import FileRunStore
|
||||
from wf_scheduling.calendar import OneShotSource
|
||||
from wf_scheduling.dispatch import StillRunning
|
||||
from wf_scheduling.models import Schedule
|
||||
from wf_scheduling.ownership import SchedulerOwnership
|
||||
from wf_scheduling.poll import Scheduler
|
||||
from wf_scheduling.prepare import 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)
|
||||
|
||||
|
||||
STRICT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {"type": "integer"},
|
||||
"nested": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"required": ["count"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
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 _strict_env(sched: Any) -> PinnedRunEnvironment:
|
||||
artifact = _artifact().model_copy(
|
||||
update={"input_schema": dict(STRICT_SCHEMA)},
|
||||
)
|
||||
deployment = _deployment().model_copy(update={"id": sched.deployment_id})
|
||||
return PinnedRunEnvironment(
|
||||
deployment=deployment, root_artifact=artifact, child_artifacts=[]
|
||||
)
|
||||
|
||||
|
||||
def _scheduler(
|
||||
tmp_path: Path,
|
||||
ownership: SchedulerOwnership,
|
||||
bindings: list[dict[str, Any]],
|
||||
*,
|
||||
script: dict | None = None,
|
||||
) -> tuple[Scheduler, FileScheduleStore, FileRunStore, list[str]]:
|
||||
calls: list[str] = []
|
||||
sched_store = FileScheduleStore(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources={},
|
||||
capacity=4,
|
||||
preparer=SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 1, "required": []}}),
|
||||
_strict_env,
|
||||
),
|
||||
dispatcher=ScriptedDispatcher(script),
|
||||
ownership=ownership,
|
||||
)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
sched_store.create_schedule(_sched_model("s", input_bindings=bindings))
|
||||
sched_store.save_consumed("s", intended - timedelta(hours=1))
|
||||
sched.sources["s"] = OneShotSource(intended)
|
||||
dispatches = cast(ScriptedDispatcher, sched.dispatcher)
|
||||
outcome = (script or {}).get("*", "complete")
|
||||
assert isinstance(outcome, str)
|
||||
|
||||
def spy(admission: Any, now: datetime) -> Any:
|
||||
calls.append(admission.id)
|
||||
if outcome == "hang":
|
||||
return StillRunning()
|
||||
from wf_scheduling.dispatch import Stopped
|
||||
|
||||
return Stopped(result=dispatches.finish(admission, outcome))
|
||||
|
||||
dispatches.script = {"*": spy}
|
||||
return sched, sched_store, run_store, calls
|
||||
|
||||
|
||||
def _rejected_entry(store: FileScheduleStore) -> dict[str, Any]:
|
||||
page = store.list_occurrences("s", limit=100)
|
||||
rows = cast(list[dict[str, Any]], page["occurrences"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "preflight-rejected"
|
||||
return rows[0]
|
||||
|
||||
|
||||
def test_wrong_scalar_type_rejects_without_admission(tmp_path: Path) -> None:
|
||||
ownership = SchedulerOwnership(tmp_path, owner="test").acquire()
|
||||
try:
|
||||
sched, store, runs, calls = _scheduler(
|
||||
tmp_path,
|
||||
ownership,
|
||||
[
|
||||
{
|
||||
"target": "count",
|
||||
"expression": {
|
||||
"kind": "literal",
|
||||
"value": "definitely not integer",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
assert sched.poll(intended) == {"s": "admit:None"}
|
||||
entry = _rejected_entry(store)
|
||||
assert "invalid-input" in entry["reason"]
|
||||
assert runs.list_runs() == []
|
||||
assert runs.list_admissions() == []
|
||||
assert calls == []
|
||||
finally:
|
||||
ownership.release()
|
||||
|
||||
|
||||
def test_nested_constraint_violation_rejects(tmp_path: Path) -> None:
|
||||
ownership = SchedulerOwnership(tmp_path, owner="test").acquire()
|
||||
try:
|
||||
sched, store, runs, calls = _scheduler(
|
||||
tmp_path,
|
||||
ownership,
|
||||
[
|
||||
{"target": "count", "expression": {"kind": "literal", "value": 3}},
|
||||
{
|
||||
"target": "nested",
|
||||
"expression": {
|
||||
"kind": "object",
|
||||
"fields": {"name": {"kind": "literal", "value": 7}},
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"}
|
||||
_rejected_entry(store)
|
||||
assert runs.list_runs() == []
|
||||
assert calls == []
|
||||
finally:
|
||||
ownership.release()
|
||||
|
||||
|
||||
def test_disallowed_extra_property_rejects(tmp_path: Path) -> None:
|
||||
ownership = SchedulerOwnership(tmp_path, owner="test").acquire()
|
||||
try:
|
||||
sched, store, runs, calls = _scheduler(
|
||||
tmp_path,
|
||||
ownership,
|
||||
[
|
||||
{"target": "count", "expression": {"kind": "literal", "value": 3}},
|
||||
{
|
||||
"target": "surprise",
|
||||
"expression": {"kind": "literal", "value": "x"},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"}
|
||||
_rejected_entry(store)
|
||||
assert runs.list_runs() == []
|
||||
assert calls == []
|
||||
finally:
|
||||
ownership.release()
|
||||
|
||||
|
||||
def test_valid_structured_input_admits(tmp_path: Path) -> None:
|
||||
ownership = SchedulerOwnership(tmp_path, owner="test").acquire()
|
||||
try:
|
||||
sched, store, runs, calls = _scheduler(
|
||||
tmp_path,
|
||||
ownership,
|
||||
[
|
||||
{"target": "count", "expression": {"kind": "literal", "value": 3}},
|
||||
{
|
||||
"target": "nested",
|
||||
"expression": {
|
||||
"kind": "object",
|
||||
"fields": {"name": {"kind": "literal", "value": "ok"}},
|
||||
},
|
||||
},
|
||||
],
|
||||
script={"*": "hang"},
|
||||
)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
result = sched.poll(intended)
|
||||
assert result["s"].startswith("admit:run-")
|
||||
run_id = result["s"].split(":", 1)[1]
|
||||
assert runs.get_admission(run_id).resolved_input == {
|
||||
"count": 3,
|
||||
"nested": {"name": "ok"},
|
||||
}
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
ownership.release()
|
||||
|
||||
|
||||
def test_changed_contract_rejects_before_admission(tmp_path: Path) -> None:
|
||||
ownership = SchedulerOwnership(tmp_path, owner="test").acquire()
|
||||
try:
|
||||
sched, store, runs, calls = _scheduler(
|
||||
tmp_path,
|
||||
ownership,
|
||||
[
|
||||
{"target": "count", "expression": {"kind": "literal", "value": 3}},
|
||||
],
|
||||
)
|
||||
# The pinned contract now additionally requires a token the
|
||||
# schedule does not provide.
|
||||
strict = dict(STRICT_SCHEMA)
|
||||
strict["required"] = ["count", "token"]
|
||||
artifact = _artifact().model_copy(update={"input_schema": strict})
|
||||
|
||||
def changed_env(sched: Any) -> PinnedRunEnvironment:
|
||||
deployment = _deployment().model_copy(update={"id": sched.deployment_id})
|
||||
return PinnedRunEnvironment(
|
||||
deployment=deployment, root_artifact=artifact, child_artifacts=[]
|
||||
)
|
||||
|
||||
sched.preparer = SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 2, "required": []}}), changed_env
|
||||
)
|
||||
assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"}
|
||||
_rejected_entry(store)
|
||||
assert runs.list_runs() == []
|
||||
assert runs.list_admissions() == []
|
||||
assert calls == []
|
||||
finally:
|
||||
ownership.release()
|
||||
|
||||
|
||||
def test_environment_built_once_per_preparation(tmp_path: Path) -> None:
|
||||
ownership = SchedulerOwnership(tmp_path, owner="test").acquire()
|
||||
try:
|
||||
builds: list[str] = []
|
||||
|
||||
def counting_env(sched: Any) -> PinnedRunEnvironment:
|
||||
builds.append(sched.id)
|
||||
return _strict_env(sched)
|
||||
|
||||
sched_store = FileScheduleStore(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
sched = Scheduler(
|
||||
schedule_store=sched_store,
|
||||
run_store=run_store,
|
||||
sources={},
|
||||
capacity=4,
|
||||
preparer=SchedulePreparer(
|
||||
DictDeployments({"dep-1": {"rev": 1, "required": []}}), counting_env
|
||||
),
|
||||
dispatcher=ScriptedDispatcher({"*": "hang"}),
|
||||
ownership=ownership,
|
||||
)
|
||||
intended = ts(2026, 9, 8, 12, 0)
|
||||
sched_store.create_schedule(
|
||||
_sched_model(
|
||||
"s",
|
||||
input_bindings=[
|
||||
{"target": "count", "expression": {"kind": "literal", "value": 1}}
|
||||
],
|
||||
)
|
||||
)
|
||||
sched_store.save_consumed("s", intended - timedelta(hours=1))
|
||||
sched.sources["s"] = OneShotSource(intended)
|
||||
sched.poll(intended)
|
||||
assert builds == ["s"]
|
||||
finally:
|
||||
ownership.release()
|
||||
Reference in New Issue
Block a user