InvokeAI Graph - Design Overview
High-level design for the graph module. Focuses on responsibilities, data flow, and how traversal works.
1) Purpose
Provide a typed, acyclic workflow model (Graph) plus a runtime scheduler (GraphExecutionState) that expands iterator patterns, tracks readiness via indegree (the number of incoming edges to a node in the directed graph), and executes nodes in class-grouped batches. In normal execution, runtime expansion happens in a separate execution graph instead of mutating the source graph.
2) Major Data Types
EdgeConnection
- Fields:
node_id: str,field: str. - Hashable; printed as
node.fieldfor readable diagnostics.
Edge
- Fields:
source: EdgeConnection,destination: EdgeConnection. - One directed connection from a specific output port to a specific input port.
AnyInvocation / AnyInvocationOutput
- Pydantic wrappers that carry concrete invocation models and outputs.
- No registry logic in this file; they are permissive containers for heterogeneous nodes.
IterateInvocation / CollectInvocation
-
Control nodes used by validation and execution:
- IterateInvocation: input
collection, outputs includeitem(and index/total). - CollectInvocation: many
iteminputs aggregated to onecollectionoutput.
- IterateInvocation: input
3) Graph (author-time model)
A container for declared nodes and edges. Does not perform iteration expansion.
3.1 Data
nodes: dict[str, AnyInvocation]- key must equalnode.id.edges: list[Edge]- zero or more.- Utility:
_get_input_edges(node_id, field?),_get_output_edges(node_id, field?)These scanself.edges(no adjacency indices in the current code).
3.2 Validation (validate_self)
Runs a sequence of checks:
-
Node ID uniqueness No duplicate IDs; map key equals
node.id. -
Endpoint existence Source and destination node IDs must exist.
-
Port existence Input ports must exist on the node class; output ports on the node's output model.
-
DAG constraint Build a flat
DiGraph(no runtime expansion) and assert acyclicity. -
Type compatibility
get_output_field_typevsget_input_field_typeandare_connection_types_compatible.Special case:
call_saved_workflowcurrently accepts dynamic destination handles of the formsaved_workflow_input::{childNodeId}::{childFieldName}as part of its temporary call-boundary contract.- Those handles are allowed through graph validation even though they are not static Python model fields on the invocation class.
- Runtime later validates them against the selected child workflow's exposed callable interface before applying values to the child graph.
- The editor preserves dynamic caller values only while the exposed field type remains compatible; type drift at the same child node/field path resets to the selected workflow's current initial value.
- Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page before selecting a workflow by name.
-
Iterator / collector structure Enforce special rules:
- Iterator's input must be
collection; its outgoing edges useitem. - Collector accepts many
iteminputs; outputs a singlecollection. - Edge fan-in to a non-collector input is rejected.
- Iterator's input must be
3.3 Edge admission (_validate_edge)
Checks a single prospective edge before insertion:
- Endpoints/ports exist.
- Destination port is not already occupied unless it's a collector
item. - Adding the edge to the flat DAG must keep it acyclic.
- Iterator/collector constraints re-checked when the edge creates relevant patterns.
3.4 Topology utilities
nx_graph()- DiGraph of declared nodes and edges.nx_graph_flat()- "flattened" DAG (still author-time; no runtime copies). Used in validation and in_prepare()during execution planning.
3.5 Mutation helpers
add_node,update_node(preserve edges, rewrite endpoints if id changes),delete_node.add_edge,delete_edge(with validation).
4) GraphExecutionState (runtime)
Holds the state for a single run. Keeps the source graph intact and materializes a separate execution graph.
GraphExecutionState is still the public runtime entry point, but most execution behavior is now delegated to a small
set of internal helper classes.
The source graph is treated as stable during normal execution, but the runtime object still exposes guarded graph mutation helpers. Those helpers reject changes once the affected nodes have already been prepared or executed.
4.1 Data
graph: Graph- source graph for the run; treated as stable during normal execution.execution_graph: Graph- materialized runtime nodes/edges. This is mutable runtime state, not an immutable audit log. LazyIfpruning may remove unselected input edges during execution, so persisted failed/completed session snapshots can contain a structurally pruned execution graph. Retry paths rebuild fromgraph, not from a previously persistedexecution_graph.executed: set[str],executed_history: list[str].results: dict[str, AnyInvocationOutput],errors: dict[str, str].prepared_source_mapping: dict[str, str]- exec id -> source id.source_prepared_mapping: dict[str, set[str]]- source id -> exec ids.indegree: dict[str, int]- unmet inputs per exec node.- Workflow-call runtime state:
workflow_call_stack- active parent call frames.workflow_call_history- completed or failed workflow-call relationships observed by this execution state.workflow_call_parent- parent workflow-call relationship metadata when this execution state is a child session.waiting_workflow_call- the call frame currently suspending this execution state, if any.waiting_workflow_call_execution- the active parent/child workflow-call relationship record for the waiting call.waiting_workflow_call_child_session- attached child execution state for the waiting workflow call, if any.max_workflow_call_depth- runtime guardrail for nested or recursive workflow calls.
- Prepared exec metadata caches:
- source node id
- iteration path
- runtime state such as pending, ready, executed, or skipped
- Ready queues grouped by class (private attrs):
_ready_queues: dict[class_name, deque[str]],_active_class: Optional[str]. Optionalready_order: list[str]to prioritize classes. Queues are rebuilt from persisted execution state when a session is deserialized.
4.2 Core methods
next()Returns the next ready exec node. If none are ready, it asks the materializer to expand more source nodes and then retries. If the execution state is paused on a workflow call boundary, it returnsNonewithout scheduling more work. Before returning a node, the runtime helper deep-copies inbound values into the node fields.complete(node_id, output)Records the result, marks the exec node executed, marks the source node executed once all of its prepared exec copies are done, then decrements downstream indegrees and enqueues newly ready nodes.
Workflow-call note:
GraphExecutionStatecan represent a paused parent execution plus an attached child execution state, but it does not itself orchestrate child execution.- In the current implementation,
DefaultSessionRunner.run_node()establishes the workflow call boundary and attaches the child execution state, whileWorkflowCallCoordinatorhandles call-specific setup andWorkflowCallQueueLifecyclelater resumes or fails the parent based on that child queue row's outcome. - Child
SessionQueueItemrows created by the coordinator now carry explicit relationship metadata such asworkflow_call_id,parent_item_id,parent_session_id,root_item_id, andworkflow_call_depth, even though the higher-level scheduler semantics are still evolving. - The
session_queueschema now has matching columns for those relationship fields, and parent queue items can enter awaitingstatus while suspended on a child workflow execution. - Queue lifecycle semantics are now partially defined for workflow-call chains:
- child success resumes the waiting parent
- multiple child queue rows may complete under one waiting parent when the called workflow contains direct batch nodes; the parent resumes only after all expected child rows complete
- child failure fails the waiting parent and can cascade upward through ancestors
- failing child rows cancel their remaining workflow-call siblings before the parent is failed
- cancelation is chain-aware across parents and children, including nested descendants of batched siblings
- "all except current" queue actions preserve the active current item plus its workflow-call chain, while still canceling or deleting unrelated waiting chains
- startup recovery cancels interrupted
in_progressorwaitingworkflow-call chains, including pending descendants - deleting a workflow-call queue row currently deletes the whole parent/child chain rather than leaving orphaned rows behind
- retry is root-oriented and should not be exposed directly on child queue rows in the UI
- child queue-row creation is cleaned up on boundary-setup failure and child fan-out is bounded by remaining queue capacity
- child workflows that mix supported batch nodes with unrelated generator nodes are rejected for now
- This is still an intermediate architecture step and should eventually be replaced by a more general parent/child execution mechanism rather than workflow-call-specific queue lifecycle handling.
4.3 Runtime helper classes
GraphExecutionState now delegates most runtime behavior to internal helpers:
_PreparedExecRegistryOwns the relationship between source graph nodes and prepared execution graph nodes, plus cached metadata such as iteration path and runtime state._ExecutionMaterializerExpands source graph nodes into concrete execution graph nodes when the scheduler runs out of ready work. It owns iterator expansion, collector grouping, prepared-parent selection, and creation of execution-graph edges. When matching prepared parents for a downstream exec node, skipped prepared exec nodes are ignored and cannot be selected as live inputs._ExecutionSchedulerOwns indegree transitions, ready queues, class batching, and downstream release on completion._ExecutionRuntimeOwns iteration-path lookup, collect input ordering, and input hydration for prepared exec nodes._IfBranchSchedulerApplies lazyIfsemantics by deferring branch-local work until the condition is known, then releasing the selected branch and skipping the unselected branch.
GraphExecutionState.model_post_init() rehydrates private runtime helpers and caches after normal construction or a
JSON/model round trip. Rehydration reconstructs prepared exec metadata, cached iteration paths, resolved If branch
state when the condition is already available, and ready queues from execution_graph, indegree, executed, and
results. This keeps persisted sessions resumable without persisting private helper objects.
4.4 Preparation (_prepare())
-
Build a flat DAG from the source graph.
-
Choose the next source node in topological order that:
- has not been prepared,
- if it is an iterator, its inputs are already executed,
- it has no unexecuted iterator ancestors.
-
If the node is a CollectInvocation: group prepared parent exec nodes by iteration path and create one collector exec node per group. A collector collapses the immediate iterator that feeds its
iteminput, but preserves enclosing iterator paths. This lets a shape such asouter_iter -> inner_collection -> inner_iter -> collect -> consumerproduce one collected result per outer iteration instead of mixing all inner items into one global collection. Incomingcollectioninputs are treated as ancestor groups and are copied into each matching descendant item group. -
Otherwise: compute all combinations of prepared iterator ancestors. For each combination, choose the prepared parent for each upstream by matching iterator ancestry, then create one exec node. If a node no longer has visible iterator ancestors because the source path crosses a collector, prepared parent iteration paths are still used to materialize one downstream exec node for each preserved collector path.
-
For each new exec node:
- Deep-copy the source node; assign a fresh ID (and
indexfor iterators). - Cache the preserved iteration path when the materializer has one, such as for grouped collectors.
- Wire edges from chosen prepared parents.
- Set
indegree = number of unmet inputs(i.e., parents not yet executed). - Try to resolve any
If-specific scheduling state. - If the node is ready and not deferred by an unresolved
If, enqueue it into its class queue.
- Deep-copy the source node; assign a fresh ID (and
4.5 Readiness and batching
_enqueue_if_ready(nid)enqueues by class name only whenindegree == 0, the node has not already executed, and the node is not deferred by an unresolvedIf._get_next_node()drains the_active_classqueue; when empty, selects the next nonempty class queue (byready_orderif set, else alphabetical), and continues. Within each class queue, ready exec nodes are ordered by iteration path so expanded iterator work runs in a stable outer-to-inner order. Optional fairness knobs can limit batch size per class; default is drain fully.
4.5.1 Indegree (what it is and how it's used)
Indegree is the number of incoming edges to a node in the execution graph that are still unmet. In this engine:
- For every materialized exec node,
indegree[node]equals the count of its prerequisite parents that have not finished yet. - A node is "ready" exactly when
indegree[node] == 0; only then is it enqueued. - When a node completes, the scheduler decrements
indegree[child]for each outgoing edge. Any child that reaches 0 is enqueued.
Example: edges A->C, B->C, C->D. Start: A:0, B:0, C:2, D:1. Run A -> C:1. Run B -> C:0 -> enqueue C.
Run C -> D:0 -> enqueue D. Run D -> done.
4.6 Input hydration (_prepare_inputs())
- For CollectInvocation: gather the materialized incoming
itemvalues intocollection, sorting inputs by iteration path so collected results are stable across expanded iterations. Incomingcollectionvalues are merged first, then incomingitemvalues are appended. By the time hydration runs, the materializer has already selected the iteration group for this collector exec node, so hydration only sees inputs that belong to that group. - For IfInvocation: hydrate only
conditionand the selected branch input. As a defensive guard against inconsistent runtime or deserialized session state, the runtime raises if the selected input edge points at an exec node with no stored runtime output. In normal scheduling this path should be unreachable. - For all others: deep-copy each incoming edge's value into the destination field. This prevents cross-node mutation through shared references.
4.7 Lazy If semantics
IfInvocation now acts as a lazy branch boundary rather than a simple value multiplexer.
- The
conditioninput must resolve first. - Nodes that are exclusive to the true or false branch can remain deferred even when their indegree is zero.
- Once the prepared
Ifnode resolves its condition:- the selected branch is released
- the unselected branch is marked skipped
- unselected input edges on the prepared
Ifexec node are pruned from the execution graph so they no longer participate in downstream indegree accounting - branch-exclusive ancestors of the unselected branch are never executed
- Skipped branch-local exec nodes may still be treated as executed for scheduling purposes, but they do not create
entries in
results. - Shared ancestors still execute if they are required by the selected branch or by any other live path in the graph.
This behavior is implemented in the runtime scheduler, not in the invocation body itself.
5) Traversal Summary
-
Author builds a valid Graph.
-
Create GraphExecutionState with that graph.
-
Loop:
node = state.next()-> may trigger_prepare()expansion.- Execute node externally ->
output. state.complete(node.id, output)-> updates indegrees,Ifstate, and ready queues.
-
Finish when
next()returnsNoneand the execution state is not paused waiting on a workflow call boundary.
In normal execution, all runtime expansion occurs in execution_graph with traceability back to source nodes.
6) Invariants
- Source Graph remains a DAG and type-consistent.
execution_graphremains a DAG.- Nodes are enqueued only when
indegree == 0and they are not deferred by an unresolvedIf. resultsanderrorsare keyed by exec node id.- Collectors aggregate
iteminputs and may also merge incomingcollectioninputs during runtime hydration. Collectors nested under iterators preserve enclosing iteration paths, so downstream consumers materialize per enclosing iteration instead of receiving a mixed collection from unrelated outer iterations. - Branch-exclusive nodes behind an unselected
Ifbranch are skipped, not failed.
7) Extensibility
- New node types: implement as Pydantic models with typed fields and outputs. Register per your invocation system;
this file accepts them as
AnyInvocation. - Scheduling policy: adjust
ready_orderto batch by class; add a batch cap for fairness without changing complexity. - Dynamic behaviors (future): can be added in
GraphExecutionStateby creating exec nodes and edges atcomplete()time, as long as the DAG invariant holds. - Workflow call boundaries:
GraphExecutionStatecan suspend a parent execution state on a workflow call, attach a child execution state, and later resume the parent without mutating the source graph.
Current limitation:
- Child workflow executions are now represented as first-class queue items. Parent resume/failure is intentionally handled by a dedicated workflow-call queue lifecycle component for this PR because no other feature currently needs a generalized dependent-queue scheduler.
- Called workflows currently require exactly one valid
workflow_returnnode to be callable at all. - A single
workflow_return_value.valuemay connect directly toworkflow_return.values; multiple named return members should be collected and then connected toworkflow_return.values. - Direct batch-special child workflows are now supported by expanding them into multiple child queue rows.
- Batch outputs may feed a named
workflow_return_value.valuedirectly. Parent resume aggregates named return maps asvalues: dict[str, list[Any]], and all rows in one batch call must return the same key set. - Generator-backed batch child workflows are now supported when the batch node is fed directly by a supported integer, float, string, or image generator.
- Connected batch child inputs produced by ordinary non-generator upstream nodes are still rejected before any child queue row is created.
- Workflow library API responses now include compatibility metadata so the frontend can disable unsupported callees before execution rather than failing only at runtime.
- Workflow library list compatibility uses structural generator-backed batch validation so list and picker rendering do not enumerate every image in board-backed generators; workflow detail and runtime execution still resolve real generator values.
- Batch-specific compatibility failures, including multiple connected inputs to one batch field, are reported as
unsupported_batch_inputrather than generic unsupported-node failures. - The workflow library list also surfaces that metadata as an informational unsupported state; workflows remain
viewable/editable even when they are not currently callable by
call_saved_workflow. - Single-user workflow CRUD socket events emit only to the admin room because every single-user socket already joins
that room, avoiding duplicate delivery through both
user:systemandadmin.
8) Error Model (selected)
DuplicateNodeIdError,NodeAlreadyInGraphErrorNodeNotFoundError,NodeFieldNotFoundErrorInvalidEdgeError,CyclicalGraphErrorNodeInputError(raised when preparing inputs for execution)
Messages favor short, precise diagnostics (node id, field, and failing condition).
9) Rationale
- Two-graph approach isolates authoring from execution expansion and keeps validation simple.
- Indegree + queues gives O(1) scheduling decisions with clear batching semantics.
- Iterator/collector separation keeps fan-out/fan-in explicit and testable.
- Deep-copy hydration avoids incidental aliasing bugs between nodes.