Durable DAG Workflows (DynamicWorkflow & DurableWorkflowEngine)¶
litestar-mcp provides a declarative Directed Acyclic Graph (DAG) workflow abstraction (DynamicWorkflow and WorkflowNode) that can run either in-process via WorkflowEngine or across distributed litestar-queues worker replicas via DurableWorkflowEngine and WorkflowStore.
Defining DAGs (DynamicWorkflow & WorkflowNode)¶
A DynamicWorkflow connects discrete Python functions (handler) and Agent turns (agent) into an acyclic dependency graph. At registration time, WorkflowEngine.validate_dependencies(workflow) verifies that all depends_on references exist and that the graph contains no cycles.
"""Snippet: durable workflow execution on litestar-queues."""
from litestar import Litestar, post
from litestar_queues import QueuePlugin
from litestar_mcp.agent.workflow import DynamicWorkflow, WorkflowContext
from litestar_mcp.queues import DurableWorkflowEngine
def step_extract(context: WorkflowContext) -> dict[str, int]:
"""Extract raw metrics."""
return {"raw_count": 42}
def step_transform(context: WorkflowContext) -> dict[str, int]:
"""Transform extracted metrics."""
raw = int(context.node_outputs["extract"]["raw_count"])
return {"processed_count": raw * 2}
metrics_pipeline = DynamicWorkflow(name="metrics_pipeline")
metrics_pipeline.add_node("extract", handler=step_extract)
metrics_pipeline.add_node("transform", handler=step_transform, depends_on=["extract"])
@post("/pipelines/metrics")
async def start_metrics_pipeline(workflow_engine: DurableWorkflowEngine) -> dict[str, str]:
"""Start a durable run; any worker sharing the queue backend executes it."""
return {"run_id": await workflow_engine.start("metrics_pipeline")}
def build() -> Litestar:
"""Build a Litestar application configured for durable workflows."""
return Litestar(
route_handlers=[start_metrics_pipeline],
plugins=[QueuePlugin(), DurableWorkflowEngine([metrics_pipeline])],
)
WorkflowNode Parameters (workflow.add_node(...))¶
| Parameter | Type | Default | Description |
|---|---|---|---|
name_or_node |
str or WorkflowNode |
(required) | Unique node identifier within the DynamicWorkflow, or a pre-constructed WorkflowNode instance. |
agent |
Agent or None |
None |
Agent executed via AgentRuntime.run_turn() when this node runs. |
handler |
Callable[[WorkflowContext], Any] or Agent or None |
None |
Sync or async callable accepting WorkflowContext and returning a JSON-serializable output (or an Agent, which is automatically normalized to node.agent). Either agent or handler must be supplied. |
depends_on |
tuple[str, ...] or list[str] or None |
() |
Upstream node names that must reach a terminal state before this node becomes ready. |
prompt |
Callable[[WorkflowContext], str] or None |
None |
Optional callable building the user prompt string for an agent node from WorkflowContext. When omitted, upstream node_outputs are joined with "\n\n" (falling back to ctx.get("input", "")). |
timeout_seconds |
float |
300.0 |
Maximum execution time in seconds enforced via anyio.fail_after(node.timeout_seconds). |
condition |
Callable[[WorkflowContext], bool] or None |
None |
Optional predicate evaluated against WorkflowContext before execution. When condition(ctx) returns False—or when any upstream dependency in depends_on did not complete with status == "completed" (should_skip)—the node is recorded with status="skipped". |
In-Process WorkflowEngine vs Distributed DurableWorkflowEngine¶
The exact same DynamicWorkflow instance can be executed in-process or distributed across litestar-queues:
| Feature | WorkflowEngine (litestar_mcp.agent.workflow) |
DurableWorkflowEngine (litestar_mcp.queues.workflow) |
|---|---|---|
| Execution Topology | Single-process AnyIO TaskGroup + memory object stream |
Distributed litestar-queues tasks (advance_workflow, execute_workflow_node, watch_workflow) |
| State Persistence | In-memory WorkflowContext returned directly by await engine.execute_run(wf) or await engine.run(wf) |
Durable WorkflowStore backed by the "litestar_mcp_workflows" Litestar Store |
| Litestar Integration | Plain Python class instantiated on demand | InitPluginProtocol registering workflow_engine DI provider and attaching to QueuePlugin via TaskDependencyChain |
| Crash Recovery | Process-bound | Per-run delayed watchdog (wf-watchdog:{run_id}:{tick}) + forever-unique node reservations (wf-node:{run_id}:{node}) |
Running In-Process with WorkflowEngine¶
from litestar_mcp.agent.workflow import DynamicWorkflow, WorkflowContext, WorkflowEngine
async def fetch_items(ctx: WorkflowContext) -> list[str]:
"""Return initial items from workflow state."""
return list(ctx.state.get("items", []))
async def count_items(ctx: WorkflowContext) -> int:
"""Count items produced by fetch_items."""
return len(ctx.node_outputs["fetch"])
pipeline = DynamicWorkflow(name="local_pipeline")
pipeline.add_node("fetch", handler=fetch_items)
pipeline.add_node("count", handler=count_items, depends_on=["fetch"])
async def run_local() -> int:
"""Execute the DAG in-process and return the count output."""
engine = WorkflowEngine()
ctx = await engine.execute_run(pipeline, initial_state={"items": ["a", "b", "c"]})
return int(ctx.node_outputs["count"])
DurableWorkflowEngine Plugin Architecture¶
Register DurableWorkflowEngine(workflows, ...) in Litestar(plugins=[QueuePlugin(...), DurableWorkflowEngine([...])]):
| Parameter | Type | Default | Description |
|---|---|---|---|
workflows |
Iterable[DynamicWorkflow] |
(required) | DynamicWorkflow definitions validated and registered in WorkflowRegistry across all web and worker processes. |
store |
Store or None |
None |
Optional Litestar Store for WorkflowStore. Defaults to the "litestar_mcp_workflows" store resolved from Litestar(stores=...). |
runtime_factory |
Callable[[Agent], AgentRuntime] or None |
None |
Optional factory mapping an Agent node target to an AgentRuntime (defaults to AgentRuntime(target=node.agent)). |
queue |
str |
"workflow" |
Target litestar-queues queue lane for workflow advance, node execution, and watchdog tasks. |
watchdog_interval |
float or None |
30.0 |
Delay in seconds (run_after) between self-rearming watch_workflow ticks. Pass None to disable the watchdog. |
run_timeout_seconds |
float |
86_400.0 |
Maximum wall-clock lifetime in seconds (24 hours) for a workflow run before the watchdog marks the run "failed" with error="workflow timed out". |
Automatic DI & TaskDependencyChain Wiring¶
During on_app_init, DurableWorkflowEngine performs three setup steps automatically:
TaskDependencyChain.attach: AppendsDurableWorkflowEngine.resolve_task_dependenciestoQueuePlugin.config.task_dependency_resolverso theworkflow_engineargument is injected intoadvance_workflow(litestar_mcp.workflow.advance),execute_workflow_node(litestar_mcp.workflow.execute_node), andwatch_workflow(litestar_mcp.workflow.watchdog). BecauseTaskDependencyChainmerges resolvers in order,QueueTaskExecutorandDurableWorkflowEnginecoexist on the sameQueuePluginwithout manual resolver wiring.- Default Store Resolution: Resolves
"litestar_mcp_workflows"fromapp_config.stores(StoreRegistry) whenstore=None. - Route Handler Dependency (
workflow_engine): Registers"workflow_engine": Provide(self._provide_engine, sync_to_thread=False)so any Litestar route handler can declareworkflow_engine: DurableWorkflowEngineto callawait workflow_engine.start(...)orawait workflow_engine.snapshot(run_id).
WorkflowStore Single-Writer Key Separation¶
When parallel sibling nodes in a DAG wave finish simultaneously on different worker replicas, updating a single shared run document would cause read-modify-write races and lost node outputs. WorkflowStore eliminates sibling write contention by separating run metadata from write-once node records:
| Key Pattern | Record Type | Writer Semantics | Contents |
|---|---|---|---|
{prefix}:{run_id}:run (default prefix litestar_mcp:workflow) |
WorkflowRun |
Created once at start(); updated monotonically when transitioning from "running" to "completed" or "failed" (set_run_status) |
run_id, workflow, state (initial state dict), status ("running", "completed", "failed"), error, created_at |
{prefix}:{run_id}:node:{name} |
NodeRecord |
Write-once per node (put_node checks store.exists(node_key) and never overwrites an existing outcome) |
node, status ("completed", "skipped", "failed"), output (JSON-serializable result), error |
To inspect a running or completed workflow, call await workflow_engine.snapshot(run_id), which loads the WorkflowRun and all persisted NodeRecord entries in parallel (store.get_nodes) and reconstructs a WorkflowContext via store.to_context(run, nodes).
Note: Node outputs must be JSON-serializable. If a node handler returns an unserializable object (
TypeError,msgspec.EncodeError, orSerializationException),DurableWorkflowEngine.execute_nodecatches the encoding failure and persists aNodeRecord(node=node, status="failed", output=None, error="TypeError: node output is not JSON serializable")so the workflow terminates deterministically instead of stalling.
Forever-Unique Node Scheduling & reset_task_identity¶
Multiple completed upstream nodes can trigger advance_workflow(run_id) concurrently. To guarantee each node in a run executes at most once:
execute_workflow_nodeis decorated with@task(EXECUTE_NODE_TASK, queue="workflow", retries=0, unique_until="forever", unique_by="arguments").DurableWorkflowEngine.advance(run_id)enqueues ready nodes withkey=f"wf-node:{run_id}:{name}". The queue backend deduplicates concurrent enqueue calls for the samewf-node:{run_id}:{name}key.- Inside
execute_node(run_id, node),WorkflowStore.get_node(run_id, node)performs an additional idempotency check before executing the node and enqueuing the nextadvance_workflow. - When all nodes in
wf.nodeshave a persistedNodeRecord(or when the run times out inwatch()),DurableWorkflowEnginefinalizesWorkflowRun.status("completed"or"failed") and callsawait service.reset_task_identity(f"wf-node:{run_id}:{name}")for every node in the workflow to release the unique key reservations cleanly.
Per-Run Watchdog Recovery¶
If a transient broker or worker crash drops an advance_workflow enqueue after a node writes its NodeRecord, the per-run watchdog heals the run automatically:
- Initial Watchdog Scheduling: When
watchdog_intervalis notNone(default30.0seconds),workflow_engine.start()enqueueswatch_workflowwithtick=1,key=f"wf-watchdog:{run_id}:1", andrun_after=self.watchdog_interval. - Backend Validation: Delayed
run_aftertasks require an asynchronous queue execution backend ("local","redis","sqlspec", etc.). IfQueuePluginis configured withexecution_backend="immediate"whilewatchdog_intervalis enabled,workflow_engine.start()raisesImproperlyConfiguredException. - Timeout & Re-Advance Loop: On each tick,
DurableWorkflowEngine.watch(run_id, tick): - Returns immediately if the run is already
"completed"or"failed". - Checks whether
time.time() - run.created_at >= self.run_timeout_seconds. If exceeded, marks the run"failed"witherror="workflow timed out"and resets allwf-node:{run_id}:{name}identities. - Calls
await self.advance(run_id)to schedule any unscheduled ready nodes or finalize the run. - If the run is still
"running", schedules ticktick + 1underkey=f"wf-watchdog:{run_id}:{tick + 1}"withrun_after=self.watchdog_interval.