Files
lda-wf/tests/scheduling/test_lifecycle.py
T

559 lines
21 KiB
Python

"""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")