sched: bind ownership to store composition; stable recovery failure; schema-checked prepare; guarded settle (R4 wave 2)

This commit is contained in:
lda
2026-09-08 18:36:09 +07:00 Verified
parent b9d8eb9d16
commit 3f1d5d158b
16 changed files with 1299 additions and 60 deletions
+298
View File
@@ -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()