Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

358 lines
20 KiB
Markdown

# 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.field` for 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 include `item` (and index/total).
- **CollectInvocation**: many `item` inputs aggregated to one `collection` output.
## 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 equal `node.id`.
- `edges: list[Edge]` - zero or more.
- Utility: `_get_input_edges(node_id, field?)`, `_get_output_edges(node_id, field?)` These scan `self.edges` (no
adjacency indices in the current code).
### 3.2 Validation (`validate_self`)
Runs a sequence of checks:
1. **Node ID uniqueness** No duplicate IDs; map key equals `node.id`.
1. **Endpoint existence** Source and destination node IDs must exist.
1. **Port existence** Input ports must exist on the node class; output ports on the node's output model.
1. **DAG constraint** Build a *flat* `DiGraph` (no runtime expansion) and assert acyclicity.
1. **Type compatibility** `get_output_field_type` vs `get_input_field_type` and `are_connection_types_compatible`.
Special case:
- `call_saved_workflow` currently accepts dynamic destination handles of the form
`saved_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.
1. **Iterator / collector structure** Enforce special rules:
- Iterator's input must be `collection`; its outgoing edges use `item`.
- Collector accepts many `item` inputs; outputs a single `collection`.
- Edge fan-in to a non-collector input is rejected.
### 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. Lazy `If` pruning may remove unselected input edges during execution, so persisted failed/completed session
snapshots can contain a structurally pruned execution graph. Retry paths rebuild from `graph`, not from a previously
persisted `execution_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]`. Optional `ready_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 returns `None` without 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:
- `GraphExecutionState` can 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, while `WorkflowCallCoordinator` handles call-specific setup and
`WorkflowCallQueueLifecycle` later resumes or fails the parent based on that child queue row's outcome.
- Child `SessionQueueItem` rows created by the coordinator now carry explicit relationship metadata such as
`workflow_call_id`, `parent_item_id`, `parent_session_id`, `root_item_id`, and `workflow_call_depth`, even though the
higher-level scheduler semantics are still evolving.
- The `session_queue` schema now has matching columns for those relationship fields, and parent queue items can enter a
`waiting` status 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_progress` or `waiting` workflow-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:
- `_PreparedExecRegistry` Owns the relationship between source graph nodes and prepared execution graph nodes, plus
cached metadata such as iteration path and runtime state.
- `_ExecutionMaterializer` Expands 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.
- `_ExecutionScheduler` Owns indegree transitions, ready queues, class batching, and downstream release on completion.
- `_ExecutionRuntime` Owns iteration-path lookup, collect input ordering, and input hydration for prepared exec nodes.
- `_IfBranchScheduler` Applies lazy `If` semantics 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:
1. has not been prepared,
1. if it is an iterator, *its inputs are already executed*,
1. 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 `item` input, but preserves enclosing
iterator paths. This lets a shape such as `outer_iter -> inner_collection -> inner_iter -> collect -> consumer`
produce one collected result per outer iteration instead of mixing all inner items into one global collection.
Incoming `collection` inputs 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 `index` for 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.
### 4.5 Readiness and batching
- `_enqueue_if_ready(nid)` enqueues by class name only when `indegree == 0`, the node has not already executed, and the
node is not deferred by an unresolved `If`.
- `_get_next_node()` drains the `_active_class` queue; when empty, selects the next nonempty class queue (by
`ready_order` if 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 `item` values into `collection`, sorting inputs by
iteration path so collected results are stable across expanded iterations. Incoming `collection` values are merged
first, then incoming `item` values 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 `condition` and 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 `condition` input 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 `If` node resolves its condition:
- the selected branch is released
- the unselected branch is marked skipped
- unselected input edges on the prepared `If` exec 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
1. Author builds a valid **Graph**.
1. Create **GraphExecutionState** with that graph.
1. Loop:
- `node = state.next()` -> may trigger `_prepare()` expansion.
- Execute node externally -> `output`.
- `state.complete(node.id, output)` -> updates indegrees, `If` state, and ready queues.
1. Finish when `next()` returns `None` and 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_graph` remains a DAG.
- Nodes are enqueued only when `indegree == 0` and they are not deferred by an unresolved `If`.
- `results` and `errors` are keyed by **exec node id**.
- Collectors aggregate `item` inputs and may also merge incoming `collection` inputs 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 `If` branch 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_order` to batch by class; add a batch cap for fairness without changing
complexity.
- **Dynamic behaviors** (future): can be added in `GraphExecutionState` by creating exec nodes and edges at `complete()`
time, as long as the DAG invariant holds.
- **Workflow call boundaries**: `GraphExecutionState` can 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_return` node to be callable at all.
- A single `workflow_return_value.value` may connect directly to `workflow_return.values`; multiple named return members
should be collected and then connected to `workflow_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.value` directly. Parent resume aggregates named return maps as
`values: 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_input` rather 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:system` and `admin`.
## 8) Error Model (selected)
- `DuplicateNodeIdError`, `NodeAlreadyInGraphError`
- `NodeNotFoundError`, `NodeFieldNotFoundError`
- `InvalidEdgeError`, `CyclicalGraphError`
- `NodeInputError` (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.