async-nodes
async-nodes is a type-safe, asynchronous node engine implementing the Bulk Synchronous Parallel (BSP) computing model in Rust - a control-flow engine with first-class loops, declarative edges, failure recovery, suspension, and serializable graph definitions.
The engine core is control-flow with BSP superstep parallelism: nodes are re-executable units over your own state type S, edges are data carrying declarative conditions, and token-based activation makes loops native topology. The classic dataflow-once pipelines this crate has always run are a profile on that core - a dataflow DAG is just a control-flow graph in which each node fires exactly once, when all its inputs are ready - not a second engine with its own semantics.
Why it's different
Most graph crates schedule DAGs. async-nodes runs workflows:
- Loops are topology, not driver code. A back-edge with an iteration budget is the loop -
max_iterationson the edge,max_stepson the engine, iteration counters in the run state. Runaway graphs end in a distinctEngineResult::StepLimitReached, never a silent partial run. - Suspension is a first-class outcome. A node can freeze the whole run mid-graph (mid-loop, even) with a payload and a list of pins it's waiting on; the host resumes it later by injecting values. Human approvals, webhooks, and long polls become one-line primitives.
- Recovery is policy, not try/catch spaghetti. Per node: fail-fast, retry with jittered exponential backoff (honoring
Retry-After-style hints), or route the failure onto a visibleOnErroredge. Error paths become drawable topology. - Graphs are data. Every graph round-trips through versioned JSON (
GraphDef): stable string ids, declarative conditions by name, per-node config blobs, GUI layout hints. A node-graph GUI can load, edit, save, and instantiate workflows - the palette, the canvas, and the save format are the same data. - Everything emits events. Supersteps, edges taken, retries, loop iterations, suspensions - one
BspInterceptortrait, with aChannelInterceptorbroadcasting overtokio::sync::broadcastfor live UIs. - Parallelism with guardrails. BSP supersteps with barrier synchronization,
with_max_concurrencythrottling, branch isolation with deterministic, declaration-order merges.
Quickstart
#[node] turns an async fn into a node with typed, named ports. Wiring is compile-time
named and validated before execution:
use ;
async
async
async
async
✅ 9 words
() is the graph's state: a pure dataflow pipeline doesn't need one. Control-flow
graphs carry your own S - the engine moves it, clones it across branches, and never
inspects it.
Show me something I can't do in a DAG scheduler
All three of these are complete, runnable programs in examples/ - heavily
commented at three different audience levels.
A loop that converges - as graph topology
examples/newton_sqrt.rs runs Newton's method as a node with a
back-edge guarded by a named condition and an iteration budget:
graph
.add_edge // the loop
.when
.with_max_iterations; // the circuit breaker
graph
.add_edge // the exit
.when;
iteration 1: guess = 154.000000000000
iteration 2: guess = 78.987012987013
iteration 5: guess = 25.017148688503
iteration 8: guess = 24.738633753706
✅ √612 ≈ 24.738633753706
converged in 8 iterations (9 supersteps)
A run that pauses for a human - then resumes mid-superstep
examples/agentic_deploy.rs is a deployment workflow with a
retrying preflight check, an approval gate that suspends the entire run, and an
always-failing notifier whose error rides a visible OnError edge:
── superstep 1 ─ frontier [2]
↻ node 2 retry #1 in 9.335ms (… simulated flake (attempt 1))
↻ node 2 retry #2 in 19ms (… simulated flake (attempt 2))
[state] preflight green
⤷ edge 2 → 3 [always]
── superstep 2 ─ frontier [3]
⏸ suspended at node 3: {"plan":[…],"question":"Approve deployment?"}
⤷ edge 3 → 4 [named:approved] ← after engine.resume(approval)
The same example then serializes the graph to JSON and re-runs it from the document - taking the other approval branch:
let def = from_json?;
let graph = from_def?; // registry rebinds closures
That is the whole n8n-style story: workflows you can draw, save, and hand to someone else.
The beginner one
examples/quickstart.rs - the pipeline above, with fan-out and a
barrier join, commented for a first-day reader.
What is Bulk Synchronous Parallel (BSP)?
The BSP model, originally proposed by Leslie Valiant, structures concurrent computation into global iterations called supersteps:
+---------------------------------------+
| SUPERSTEP EXECUTION LOOP |
+---------------------------------------+
| Phase 1: Concurrent Computation |
| - Workers execute local logic |
+-------------------+-------------------+
|
v
+-------------------+-------------------+
| Phase 2: Communication |
| - Outputs routed to target inputs |
+-------------------+-------------------+
|
v
+-------------------+-------------------+
| Phase 3: Barrier Synchronization |
| - All tasks complete before step |
| increment |
+-------------------+-------------------+
- Concurrent Computation: nodes perform computation independently using locally available inputs.
- Communication: outputs of completed nodes are routed to the inputs of downstream waiting nodes.
- Barrier Synchronization: all active tasks in the current superstep complete before the step counter increments; the next superstep's frontier is computed from freshly satisfied edges and inputs.
Structuring concurrency into explicit supersteps eliminates data races, ad-hoc locks, and unstructured state tracking - execution stays deterministic, predictable, and debuggable.
Dependency & Context Injection
Nodes often need shared, thread-safe dependencies (connection pools, clients, config) that
don't originate from graph data flow. BspContext is a type-keyed DI store; the #[context]
attribute extracts and clones dependencies before execution, so no lock guards survive across
.await points.
use Arc;
use TypeMapKey;
use node;
async
For reference-counted heavy resources, map the key to its Arc form explicitly:
async
Attach the context at engine construction:
use ;
async
Critical Execution & Safety Guidelines
1. Cooperative Concurrency
The engine drives supersteps concurrently via cooperative stream buffering. Non-blocking
#[node] async methods must remain cooperative - periodically tokio::task::yield_now().await
inside long computations, or use #[node_blocking] for CPU-bound work (it routes through
tokio::task::spawn_blocking).
2. Re-entrancy & Idempotency on Resumption
When a graph is suspended via NodeOutcome::Suspend and later resumed, the suspended node's
execute block runs from the very beginning (with the injected inputs now present).
Write suspend-capable nodes idempotently: check whether side effects already happened before
repeating them.
3. Skip Propagation Behavior
Dynamic bypass uses BspSkip propagation: if any incoming input to a dataflow node carries a
BspSkip marker (even into an Option<T> port), the node's execution is bypassed and the
skip propagates downstream. Branch selection in the control-flow layer should use mutually
exclusive edge conditions instead.
4. Loops Are Legal - and Bounded
Cycles are native topology: a back-edge re-activates an earlier node, and activation tokens (not an "already executed" set) drive scheduling. Because runaway loops are possible by construction, bounds are part of the core:
- Put
max_iterationson every cycle-closing edge (BspError::LoopBoundExceededwhen tripped). Validation warns on cycles lacking one. - The global
with_max_stepsbudget (default 1024 supersteps) ends runaway runs with a distinctEngineResult::StepLimitReachedcarrying the live state - distinguish it from natural completion instead of treating it as one. - Joins inside loops are almost always
Activation::Anyor merger-joins. AnAll-join on a cycle starves: one incoming edge's token arrives once per iteration while another's arrives once ever. Validation warns on this shape; at runtime it ends inBspError::Deadlock. - Retry policies re-invoke
executeverbatim. AttachRecoveryPolicy::Retryonly to read-only nodes, or to nodes that undo partial writes on re-entry viaRunContext.execution_no- retrying a side-effecting node replays the side effect.
Examples
| Example | Level | Shows off |
|---|---|---|
quickstart.rs |
Beginner | #[node] macros, typed named ports, fan-out + barrier join, typed outputs |
newton_sqrt.rs |
Advanced | custom BspNode<S>, named conditions, back-edge + max_iterations, entries, counters |
agentic_deploy.rs |
Mastery | retry policy, suspend/resume HITL, OnError edges, live event trace, GraphDef JSON round-trip through a registry |
Run any of them:
License
MIT