sched: server lifecycle service with bounded real execution (T12 core)
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user