sched: server lifecycle service with bounded real execution (T12 core)

This commit is contained in:
lda
2026-09-09 10:53:48 +07:00 Verified
parent c0d36d6b61
commit 42deba6399
6 changed files with 1195 additions and 52 deletions
+558
View File
@@ -0,0 +1,558 @@
"""Opt-in server scheduler lifecycle (T12): service behavior over real stores.
The service owns canonical ownership, startup recovery (never executes),
periodic polling that never blocks on long workflows, bounded execution
tasks behind the async-completion seam, and stop-admission-first drain
with truthful abandonment. Execution itself is scripted here through a
stub async runtime; real-workflow and real-server integration arrives in
dedicated integration tests.
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, cast
import pytest
from tests.artifacts.test_run_store import artifact as _artifact
from tests.artifacts.test_run_store import deployment as _deployment
from wf_artifacts.runs.store import FileRunStore
from wf_artifacts.store import FileWorkflowArtifactStore
from wf_core import END, RunState, RunStatus
from wf_scheduling.lifecycle import (
DrainReport,
SchedulerService,
SchedulerServiceConfig,
SchedulerStartupError,
StoreDeploymentDirectory,
)
from wf_scheduling.models import Schedule
from wf_scheduling.ownership import SchedulerOwnership, SecondOwnerError
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, intended: datetime, **kw: Any) -> Schedule:
base: dict[str, Any] = {
"id": sid,
"deployment_id": "parent.personal",
"trigger": {"kind": "oneshot", "at": intended.isoformat()},
"input_bindings": [],
"created_at": intended.isoformat(),
"updated_at": intended.isoformat(),
}
base.update(kw)
return Schedule.model_validate(base)
def _stopped(status: RunStatus) -> RunState:
return RunState(
workflow_name="sched",
status=status,
workflow_input={},
state={},
)
class ScriptedRuntime:
"""Stub async runtime with per-test outcomes (never touches stores)."""
def __init__(self, outcome: Any = "complete") -> None:
self.outcome = outcome
self.calls: list[dict[str, Any]] = []
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run_workflow_from_plan(
self,
plan: Any,
workflow_input: dict[str, Any],
deployment: Any = None,
artifact: Any = None,
saved_subgraph_tree: Any = None,
limits: Any = None,
) -> RunState:
self.calls.append(
{
"plan": plan,
"input": workflow_input,
"deployment": deployment,
"artifact": artifact,
"limits": limits,
}
)
spec = self.outcome
if callable(spec):
spec = spec(len(self.calls))
if spec == "hang":
self.started.set()
await self.release.wait()
return _stopped(RunStatus.COMPLETED)
if spec == "raise":
raise RuntimeError("injected execution failure")
assert isinstance(spec, str), f"unknown outcome spec {spec!r}"
return _stopped(
{
"complete": RunStatus.COMPLETED,
"fail": RunStatus.FAILED,
"interrupt": RunStatus.INTERRUPTED,
}[spec]
)
async def resume_workflow_from_plan(self, *args: Any, **kwargs: Any) -> RunState:
raise AssertionError("scheduler never resumes through the runtime")
def _artifact_store(tmp_path: Path) -> FileWorkflowArtifactStore:
store = FileWorkflowArtifactStore(tmp_path / "wf")
store.save_artifact(_artifact().model_copy(update={"plan": _plan_dict()}))
store.save_deployment(_deployment())
return store
def _plan_dict() -> dict[str, Any]:
"""Minimal executable plan (constant workflow, proven shape)."""
return {
"name": "parent",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
},
"output_schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
},
"outcomes": ["ok"],
"start": "constant",
"nodes": [
{
"id": "constant",
"type": "node",
"node": "wf.std.constant",
"input": [
{
"value": "hello from scheduler",
"target": {"root": "local", "parts": ["value"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["value"]},
"target": {"root": "state", "parts": ["result"]},
}
],
}
],
"edges": [{"from": "constant", "outcome": "ok", "to": END}],
"output": [
{
"path": {"root": "state", "parts": ["result"]},
"target": {"root": "local", "parts": ["result"]},
}
],
}
def _service(
tmp_path: Path,
runtime: Any,
config: SchedulerServiceConfig | None = None,
) -> SchedulerService:
if config is None:
# Deterministic tests drive ticks explicitly through poll_once;
# the background loop has its own test below.
config = SchedulerServiceConfig(poll_interval_s=0.01, auto_tick=False)
return SchedulerService(
schedule_store=FileScheduleStore(tmp_path),
run_store=FileRunStore(tmp_path),
runtime=runtime,
artifact_store=_artifact_store(tmp_path),
ownership=SchedulerOwnership(tmp_path, owner="test"),
config=config,
)
async def _wait_for(cond: Any, timeout: float = 10.0) -> None:
async with asyncio.timeout(timeout):
while not cond():
await asyncio.sleep(0.01)
def _entries(store: FileScheduleStore, sid: str, kind: str) -> list[dict[str, Any]]:
page = store.list_occurrences(sid, limit=100)
rows = cast(list[dict[str, Any]], page["occurrences"])
return [r for r in rows if r["kind"] == kind]
def _only_run_id(run_store: Any) -> str:
runs = list(run_store.list_runs())
assert len(runs) == 1
return runs[0].id
async def test_scheduled_completion_through_lifecycle(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
service = _service(tmp_path, ScriptedRuntime("complete"))
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
result = await service.poll_once(intended + timedelta(seconds=1))
run_id = _only_run_id(service.run_store)
assert result == {"a": f"admit:{run_id}"}
await _wait_for(lambda: service.live_executions == 0)
record = service.run_store.get_run(run_id)
assert record.status.value == "completed"
assert len(_entries(service.schedule_store, "a", "completed")) == 1
# The dispatcher ran the pinned deployment input, not a fixture.
assert len(service.runtime.calls) == 1
assert service.runtime.calls[0]["input"] == {}
assert service.runtime.calls[0]["deployment"].id == "parent.personal"
assert service.runtime.calls[0]["limits"].max_steps == 10_000
finally:
report = await service.stop()
assert report.settled == 1
assert report.cancelled == 0
async def test_scheduled_failure_records_failed_history(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
service = _service(tmp_path, ScriptedRuntime("fail"))
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
result = await service.poll_once(intended + timedelta(seconds=1))
run_id = _only_run_id(service.run_store)
assert result == {"a": f"admit:{run_id}"}
await _wait_for(lambda: service.live_executions == 0)
assert service.run_store.get_run(run_id).status.value == "failed"
assert len(_entries(service.schedule_store, "a", "failed")) == 1
finally:
await service.stop()
async def test_scheduled_interrupt_stays_resumable(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
service = _service(tmp_path, ScriptedRuntime("interrupt"))
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
await service.poll_once(intended + timedelta(seconds=1))
run_id = _only_run_id(service.run_store)
await _wait_for(lambda: service.live_executions == 0)
record = service.run_store.get_run(run_id)
assert record.status.value == "interrupted"
assert record.resume_readiness.value == "ready"
assert len(_entries(service.schedule_store, "a", "interrupted")) == 1
finally:
await service.stop()
async def test_sources_refresh_after_start(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
service = _service(tmp_path, ScriptedRuntime("complete"))
try:
await service.start()
assert await service.poll_once(intended) == {}
service.schedule_store.create_schedule(_sched_model("a", intended))
result = await service.poll_once(intended + timedelta(seconds=1))
assert result["a"].startswith("admit:run-")
await _wait_for(lambda: service.live_executions == 0)
finally:
await service.stop()
async def test_hang_then_late_settlement_frees_capacity(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
runtime = ScriptedRuntime("hang")
service = _service(
tmp_path,
runtime,
SchedulerServiceConfig(poll_interval_s=0.01, capacity=1, auto_tick=False),
)
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
service.schedule_store.create_schedule(_sched_model("b", intended))
first = await service.poll_once(intended + timedelta(seconds=1))
assert first["a"].startswith("admit:run-")
await _wait_for(lambda: runtime.started.is_set())
run_a = _only_run_id(service.run_store)
assert service.run_store.get_run(run_a).status.value == "admitted"
assert service.live_executions == 1
# Capacity is saturated: no second admission while the first hangs.
await service.poll_once(intended + timedelta(seconds=2))
assert [r.id for r in list(service.run_store.list_runs())] == [run_a]
runtime.release.set()
await _wait_for(lambda: service.live_executions == 0)
assert service.run_store.get_run(run_a).status.value == "completed"
# The freed slot admits the waiting schedule on the next tick.
later = await service.poll_once(intended + timedelta(seconds=3))
assert later["b"].startswith("admit:run-")
finally:
runtime.release.set()
await service.stop()
async def test_long_run_does_not_block_calendar_polling(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
calls = {"n": 0}
def outcome(n: int) -> str:
calls["n"] = n
return "hang" if n == 1 else "complete"
runtime = ScriptedRuntime(outcome)
service = _service(
tmp_path,
runtime,
SchedulerServiceConfig(poll_interval_s=0.01, capacity=2, auto_tick=False),
)
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
await service.poll_once(intended + timedelta(seconds=1))
await _wait_for(lambda: runtime.started.is_set())
run_a = _only_run_id(service.run_store)
# A second schedule admitted while the first still executes.
service.schedule_store.create_schedule(_sched_model("b", intended))
result = await service.poll_once(intended + timedelta(seconds=2))
assert result["b"].startswith("admit:run-")
await _wait_for(lambda: service.live_executions == 1)
run_b = result["b"].split(":", 1)[1]
assert service.run_store.get_run(run_b).status.value == "completed"
assert service.run_store.get_run(run_a).status.value == "admitted"
assert service.live_executions <= 2
finally:
runtime.release.set()
await service.stop()
async def test_graceful_shutdown_cancels_hanging_execution(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
runtime = ScriptedRuntime("hang")
service = _service(
tmp_path,
runtime,
SchedulerServiceConfig(
poll_interval_s=0.01, drain_grace_s=0.05, auto_tick=False
),
)
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
await service.poll_once(intended + timedelta(seconds=1))
await _wait_for(lambda: runtime.started.is_set())
run_id = _only_run_id(service.run_store)
report = await service.stop()
assert report.cancelled == 1
assert report.settled == 0
# Truthful handling: the run keeps its executing mark, settled by
# restart recovery instead of a fabricated outcome.
record = FileRunStore(tmp_path).get_run(run_id)
assert record.status.value == "admitted"
assert FileRunStore(tmp_path).is_executing(run_id) is True
assert service.live_executions == 0
finally:
runtime.release.set()
await service.stop()
async def test_restart_after_shutdown_abandons_without_replay(
tmp_path: Path,
) -> None:
intended = ts(2026, 9, 8, 12, 0)
runtime = ScriptedRuntime("hang")
first = _service(
tmp_path,
runtime,
SchedulerServiceConfig(
poll_interval_s=0.01, drain_grace_s=0.05, auto_tick=False
),
)
try:
await first.start()
first.schedule_store.create_schedule(_sched_model("a", intended))
await first.poll_once(intended + timedelta(seconds=1))
await _wait_for(lambda: runtime.started.is_set())
run_id = _only_run_id(first.run_store)
await first.stop()
finally:
runtime.release.set()
await first.stop()
assert len(runtime.calls) == 1
# Fresh instances across the restart, like a real process boundary.
second = _service(tmp_path, ScriptedRuntime("complete"))
try:
await second.start()
record = second.run_store.get_run(run_id)
assert record.status.value == "failed"
assert record.resume_readiness.value == "not_applicable"
assert len(_entries(second.schedule_store, "a", "failed")) == 1
assert len(second.runtime.calls) == 0
finally:
await second.stop()
async def test_dispatcher_error_abandons_without_replay(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
service = _service(tmp_path, ScriptedRuntime("raise"))
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
await service.poll_once(intended + timedelta(seconds=1))
run_id = _only_run_id(service.run_store)
await _wait_for(lambda: service.live_executions == 0)
record = service.run_store.get_run(run_id)
assert record.status.value == "failed"
assert record.resume_readiness.value == "not_applicable"
assert len(_entries(service.schedule_store, "a", "failed")) == 1
assert any(run_id in message for message in service.errors)
# The slot is freed: a later schedule still admits.
service.schedule_store.create_schedule(_sched_model("b", intended))
later = await service.poll_once(intended + timedelta(seconds=2))
assert later["b"].startswith("admit:run-")
finally:
await service.stop()
async def test_failed_startup_releases_lock(tmp_path: Path) -> None:
squatter = SchedulerOwnership(tmp_path, owner="squatter").acquire()
try:
service = _service(tmp_path, ScriptedRuntime("complete"))
with pytest.raises(SchedulerStartupError):
await service.start()
assert service.running is False
finally:
squatter.release()
service = _service(tmp_path, ScriptedRuntime("complete"))
try:
await service.start()
assert service.running is True
finally:
await service.stop()
async def test_corrupt_store_startup_releases_lock(tmp_path: Path) -> None:
class BrokenRuns(FileRunStore):
def list_admissions(self) -> list[Any]:
raise OSError("injected store failure")
service = SchedulerService(
schedule_store=FileScheduleStore(tmp_path),
run_store=BrokenRuns(tmp_path),
runtime=ScriptedRuntime("complete"),
artifact_store=_artifact_store(tmp_path),
ownership=SchedulerOwnership(tmp_path, owner="test"),
config=SchedulerServiceConfig(poll_interval_s=0.01, auto_tick=False),
)
with pytest.raises(SchedulerStartupError):
await service.start()
# The lock is free for a retry after the operator fixes the store.
retry = SchedulerOwnership(tmp_path, owner="retry").acquire()
retry.release()
async def test_double_start_rejected_and_stop_idempotent(
tmp_path: Path,
) -> None:
assert await _service(tmp_path, ScriptedRuntime()).stop() == DrainReport()
service = _service(tmp_path, ScriptedRuntime("complete"))
try:
await service.start()
with pytest.raises(SchedulerStartupError):
await service.start()
finally:
await service.stop()
# Ownership is released only after the drain finishes.
freed = SchedulerOwnership(tmp_path, owner="freed").acquire()
freed.release()
async def test_second_owner_rejected_while_running(tmp_path: Path) -> None:
service = _service(tmp_path, ScriptedRuntime("complete"))
try:
await service.start()
with pytest.raises(SecondOwnerError):
SchedulerOwnership(tmp_path, owner="intruder").acquire()
finally:
await service.stop()
async def test_tick_error_recorded_and_loop_continues(tmp_path: Path) -> None:
intended = ts(2026, 9, 8, 12, 0)
service = _service(tmp_path, ScriptedRuntime("complete"))
try:
await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended))
service.schedule_store.create_schedule(_sched_model("broken", intended))
# Corrupt one schedule file behind the model's back: the listing
# fails loudly instead of ticking a half-blind schedule set.
broken_path = tmp_path / "schedules" / "broken" / "schedule.json"
good_text = broken_path.read_text(encoding="utf-8")
broken_path.write_text('{"id": "broken", "trigger": {"kind": "bogus"}}')
with pytest.raises(Exception):
await service.poll_once(intended + timedelta(seconds=1))
assert service.last_tick_error is not None
assert service.run_store.list_runs() == []
# Repairing the file resumes normal ticking on the next call.
broken_path.write_text(good_text, encoding="utf-8")
result = await service.poll_once(intended + timedelta(seconds=2))
assert result["a"].startswith("admit:run-")
await _wait_for(lambda: service.live_executions == 0)
finally:
await service.stop()
async def test_poll_once_before_start_rejected(tmp_path: Path) -> None:
service = _service(tmp_path, ScriptedRuntime("complete"))
with pytest.raises(SchedulerStartupError):
await service.poll_once(ts(2026, 9, 8, 12, 0))
await service.stop()
async def test_background_loop_ticks_and_stops(tmp_path: Path) -> None:
from datetime import timezone
service = _service(
tmp_path,
ScriptedRuntime("complete"),
SchedulerServiceConfig(poll_interval_s=0.01, auto_tick=True),
)
try:
await service.start()
# A one-shot due in real time: the background loop admits it
# without any explicit poll_once call.
due = datetime.now(timezone.utc) + timedelta(seconds=0.2)
service.schedule_store.create_schedule(_sched_model("a", due))
def _status() -> str | None:
runs = list(service.run_store.list_runs())
return runs[0].status.value if runs else None
# Status moves monotonically admitted -> completed: waiting on it
# cannot miss a fast execution the way a live-task edge can.
await _wait_for(lambda: _status() == "completed")
run_id = _only_run_id(service.run_store)
assert service.run_store.get_run(run_id).status.value == "completed"
finally:
report = await service.stop()
assert report.settled == 1
assert service.tick_count >= 2
async def test_store_deployment_directory_contract(tmp_path: Path) -> None:
store = _artifact_store(tmp_path)
directory = StoreDeploymentDirectory(store)
assert directory.deployment_revision("parent.personal") >= 1
assert directory.required_inputs("parent.personal") == []
with pytest.raises(KeyError):
directory.deployment_revision("missing.deployment")
with pytest.raises(KeyError):
directory.required_inputs("missing.deployment")
+39 -10
View File
@@ -208,12 +208,12 @@ def test_canonical_windows_path_handling_retained(tmp_path: Path) -> None:
def test_non_sibling_store_pairs_have_no_lock_identity(tmp_path: Path) -> None:
"""Only distinct siblings under one composition root share a lock file.
"""Cross pairs reusing one protected store have no single lock file.
Cross pairs that reuse one protected store (shared schedule store or
shared run store), nested pairs, and same-directory dual use would map
to different lock files while covering the same store files, so they
have no lock identity at all: guards must reject them outright.
A shared schedule store (or run store) with a different partner maps
to no lock identity: no held lock can authorize such a pair, so two
compositions can never gain independent authority over the same
store files through different layouts.
"""
overlap = tmp_path / "overlap"
sched_store, run_store = _stores(overlap)
@@ -222,11 +222,40 @@ def test_non_sibling_store_pairs_have_no_lock_identity(tmp_path: Path) -> None:
assert canonical_lock_root(sched_store.root, other_runs.root) is None
# Shared run store, different schedule store.
assert canonical_lock_root(other_sched.root, run_store.root) is None
# Nested pairs: one store inside the other.
assert canonical_lock_root(overlap, sched_store.root) is None
assert canonical_lock_root(sched_store.root, overlap) is None
# Same directory serving as both stores.
assert canonical_lock_root(sched_store.root, sched_store.root) is None
# Split layouts across different parents.
assert canonical_lock_root(sched_store.root, tmp_path / "elsewhere") is None
def test_nested_and_shared_roots_unify_on_one_lock(tmp_path: Path) -> None:
"""Identical and nested roots map to the same single lock file.
The server layout points both stores at the composition root itself,
and a nested pair shares its outer root: every composition covering
the same store files through identical, sibling, or nested roots
contends on one lock file instead of holding independent locks.
"""
overlap = tmp_path / "overlap"
sched_store, run_store = _stores(overlap)
assert canonical_lock_root(overlap, overlap) == canonical_store_path(overlap)
assert canonical_lock_root(
sched_store.root, sched_store.root
) == canonical_store_path(sched_store.root)
assert canonical_lock_root(overlap, sched_store.root) == canonical_store_path(
overlap
)
assert canonical_lock_root(sched_store.root, overlap) == canonical_store_path(
overlap
)
# One lock file: a second owner of the unified identity is rejected.
owner = SchedulerOwnership(overlap, owner="owner").acquire()
try:
assert owner.covers(overlap, overlap) is True
with pytest.raises(SecondOwnerError):
SchedulerOwnership(overlap, owner="second").acquire()
nested = SchedulerOwnership(sched_store.root, owner="nested")
assert nested.covers(sched_store.root, overlap) is False
finally:
owner.release()
def test_shared_schedule_store_cannot_gain_independent_authority(
+16
View File
@@ -223,3 +223,19 @@ def test_preparer_rejection_type_shape() -> None:
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"