09-agentic-workflow-sys / tests /test_persistence.py
FlyingNunchucks's picture
Sync from GitHub via hub-sync
152be26 verified
Raw History Blame Contribute Delete
8.14 kB
from pathlib import Path
import pytest
from src.events import record_event
from src.executor import (
WorkflowResumeError,
execute_workflow,
resume_workflow,
)
from src.persistence import (
InMemoryStateStore,
RunNotFoundError,
SQLiteStateStore,
)
from src.registry import HandlerRegistry
from src.schemas import (
EventType,
NodeDefinition,
NodeRun,
NodeStatus,
NodeType,
WorkflowDefinition,
WorkflowRun,
WorkflowStatus,
)
def make_two_step_workflow(*, version: str = "1.0") -> WorkflowDefinition:
return WorkflowDefinition(
workflow_id="persistence-workflow",
name="Persistence Workflow",
version=version,
nodes=[
NodeDefinition(
node_id="first",
name="First",
node_type=NodeType.TASK,
handler="first_handler",
),
NodeDefinition(
node_id="second",
name="Second",
node_type=NodeType.TASK,
handler="second_handler",
depends_on=["first"],
),
],
)
def test_in_memory_store_returns_detached_snapshot() -> None:
store = InMemoryStateStore()
run = WorkflowRun(
run_id="run-1",
workflow_id="workflow-1",
context={"value": 1},
)
store.save(run)
run.context["value"] = 99
loaded = store.load("run-1")
assert loaded.context == {"value": 1}
loaded.context["value"] = 7
assert store.load("run-1").context == {"value": 1}
def test_in_memory_store_rejects_unknown_run() -> None:
store = InMemoryStateStore()
with pytest.raises(RunNotFoundError, match="was not found"):
store.load("missing")
def test_sqlite_store_round_trips_nested_workflow_state(tmp_path: Path) -> None:
store = SQLiteStateStore(tmp_path / "workflow.db")
run = WorkflowRun(
run_id="sqlite-run",
workflow_id="workflow-1",
workflow_version="2.1",
status=WorkflowStatus.RUNNING,
node_runs={
"task": NodeRun(
node_id="task",
status=NodeStatus.COMPLETED,
attempt=1,
output={"ok": True},
)
},
context={"request": "synthetic"},
)
record_event(run, EventType.WORKFLOW_STARTED)
record_event(run, EventType.NODE_COMPLETED, node_id="task")
store.save(run)
loaded = store.load("sqlite-run")
assert loaded == run
assert loaded.events[0].event_type is EventType.WORKFLOW_STARTED
assert loaded.node_runs["task"].output == {"ok": True}
def test_sqlite_store_upserts_latest_checkpoint(tmp_path: Path) -> None:
store = SQLiteStateStore(tmp_path / "workflow.db")
run = WorkflowRun(run_id="run-1", workflow_id="workflow-1")
store.save(run)
run.status = WorkflowStatus.COMPLETED
run.final_output = {"done": True}
store.save(run)
loaded = store.load("run-1")
assert loaded.status is WorkflowStatus.COMPLETED
assert loaded.final_output == {"done": True}
def test_sqlite_store_rejects_unknown_run(tmp_path: Path) -> None:
store = SQLiteStateStore(tmp_path / "workflow.db")
with pytest.raises(RunNotFoundError, match="was not found"):
store.load("missing")
class SimulatedProcessStop(BaseException):
pass
class StopAfterFirstCompletionStore:
"""Save a safe checkpoint, then simulate process termination once."""
def __init__(self) -> None:
self.inner = InMemoryStateStore()
self.triggered = False
def save(self, run: WorkflowRun) -> None:
self.inner.save(run)
first = run.node_runs.get("first")
second = run.node_runs.get("second")
if (
not self.triggered
and first is not None
and second is not None
and first.status is NodeStatus.COMPLETED
and second.status is NodeStatus.PENDING
):
self.triggered = True
raise SimulatedProcessStop()
def load(self, run_id: str) -> WorkflowRun:
return self.inner.load(run_id)
def test_safe_checkpoint_resume_skips_completed_node_after_process_stop() -> None:
registry = HandlerRegistry()
first_calls = 0
second_calls = 0
def first_handler(payload: dict) -> dict:
nonlocal first_calls
first_calls += 1
return {"first": "done"}
def second_handler(payload: dict) -> dict:
nonlocal second_calls
second_calls += 1
assert payload["dependencies"] == {"first": {"first": "done"}}
return {"second": "done"}
registry.register("first_handler", first_handler)
registry.register("second_handler", second_handler)
workflow = make_two_step_workflow()
store = StopAfterFirstCompletionStore()
with pytest.raises(SimulatedProcessStop):
execute_workflow(
workflow,
registry,
run_id="resume-run",
state_store=store,
)
checkpoint = store.load("resume-run")
assert checkpoint.status is WorkflowStatus.RUNNING
assert checkpoint.node_runs["first"].status is NodeStatus.COMPLETED
assert checkpoint.node_runs["second"].status is NodeStatus.PENDING
assert first_calls == 1
assert second_calls == 0
resumed = resume_workflow(
workflow,
registry,
run_id="resume-run",
state_store=store,
)
assert resumed.status is WorkflowStatus.COMPLETED
assert resumed.node_runs["first"].attempt == 1
assert resumed.node_runs["second"].attempt == 1
assert first_calls == 1
assert second_calls == 1
assert EventType.WORKFLOW_RESUMED in [event.event_type for event in resumed.events]
assert resumed.final_output == {"second": {"second": "done"}}
def test_completed_run_resume_is_idempotent() -> None:
registry = HandlerRegistry()
calls = 0
def handler(payload: dict) -> dict:
nonlocal calls
calls += 1
return {"ok": True}
registry.register("first_handler", handler)
registry.register("second_handler", handler)
workflow = make_two_step_workflow()
store = InMemoryStateStore()
original = execute_workflow(
workflow,
registry,
run_id="completed-run",
state_store=store,
)
original_event_ids = [event.event_id for event in original.events]
resumed = resume_workflow(
workflow,
registry,
run_id="completed-run",
state_store=store,
)
assert calls == 2
assert resumed.status is WorkflowStatus.COMPLETED
assert [event.event_id for event in resumed.events] == original_event_ids
def test_resume_rejects_changed_workflow_version() -> None:
store = InMemoryStateStore()
run = WorkflowRun(
run_id="run-1",
workflow_id="persistence-workflow",
workflow_version="1.0",
status=WorkflowStatus.RUNNING,
node_runs={
"first": NodeRun(node_id="first", status=NodeStatus.COMPLETED),
"second": NodeRun(node_id="second", status=NodeStatus.PENDING),
},
)
store.save(run)
with pytest.raises(WorkflowResumeError, match="version"):
resume_workflow(
make_two_step_workflow(version="2.0"),
HandlerRegistry(),
run_id="run-1",
state_store=store,
)
def test_resume_rejects_ambiguous_in_flight_node() -> None:
store = InMemoryStateStore()
run = WorkflowRun(
run_id="run-1",
workflow_id="persistence-workflow",
workflow_version="1.0",
status=WorkflowStatus.RUNNING,
node_runs={
"first": NodeRun(node_id="first", status=NodeStatus.COMPLETED),
"second": NodeRun(node_id="second", status=NodeStatus.RUNNING, attempt=1),
},
)
store.save(run)
with pytest.raises(WorkflowResumeError, match="ambiguous or non-resumable"):
resume_workflow(
make_two_step_workflow(),
HandlerRegistry(),
run_id="run-1",
state_store=store,
)