Graph — Directed Acyclic Task Orchestration
5.43 kB minified + gzipped
When asynchronous tasks have partial interdependencies — Task C depends on Task A, while Task D
depends on both Task A and Task B — flat parallel combinators like Task.Result.all or
Promise.all are insufficient. Developers frequently resort to brittle pyramids of nested
Promise.all calls, positional tuple indexing, or manual variable coordination:
This manual coordination introduces three severe failure modes in production applications:
- Accidental Serialization: Independent steps are often sequenced unnecessarily, artificially increasing end-to-end request latency.
- Redundant Execution in Diamond Dependencies: If multiple downstream tasks depend on the same
upstream task (e.g., both
permissionsandbillingrequireuser), naive branching often executes the shared task multiple times. - Leaked Background Work: If one branch fails midway, in-flight sibling tasks continue running in the background, wasting database connections, CPU cycles, and network bandwidth.
Graph eliminates this boilerplate by providing a declarative Directed Acyclic Graph (DAG)
orchestrator. You declare steps and their upstream dependencies as typed nodes; Graph schedules
them with maximum parallelism, guarantees that diamond dependencies run exactly once, and cancels
in-flight siblings immediately on any failure.
flowchart TD
User["user (fetchUser)"] --> Permissions["permissions (fetchPermissions)"]
User --> Billing["billing (fetchBilling)"]
Config["config (fetchAppConfig)"] --> Billing
User --> Summary["summary (buildSummary)"]
Permissions --> Summary
Billing --> Summary
Defining Nodes
Section titled “Defining Nodes”Individual workflow steps are defined using Graph.node. Each node declares its unique id, its
upstream deps, and an execution thunk run:
When a node requires upstream data, list the required nodes in deps. The run callback
contextually receives the resolved values with full TypeScript type inference:
Notice that { user, config } are typed automatically from the output of their respective nodes.
Zero type annotations or manual casts are required.
Compiling a Graph
Section titled “Compiling a Graph”A graph is compiled and validated by specifying the target sink node with Graph.make:
During compilation, Graph.make:
- Traverses all reachable upstream ancestors.
- Asserts that all node identifiers within the graph are unique.
- Executes Kahn’s algorithm to prove the graph is acyclic. If an indirect cycle exists across files
or modules,
Graph.makethrows immediately at startup, preventing runtime deadlocks. - Pre-computes topological execution tiers and reverse-dependency adjacency lists for fast runtime scheduling.
Executing the Graph
Section titled “Executing the Graph”Graph.run evaluates a compiled graph, returning a standard lazy, infallible Task.Result<E, Out>:
Because Graph.run returns a Task.Result, it composes directly into standard pipelines:
Concurrency Control
Section titled “Concurrency Control”By default, Graph.run schedules all nodes with satisfied dependencies concurrently with unbounded
parallelism (Infinity). When interacting with rate-limited APIs or bounded database connection
pools, restrict the maximum active worker count using the concurrency option:
The scheduler maintains an internal in-degree ready queue, dispatching newly unblocked nodes as concurrency slots become available.
Fault Isolation & Sibling Cancellation
Section titled “Fault Isolation & Sibling Cancellation”If any node fails with Result.make.err or throws an uncaught exception:
- An internal
AbortControllerfires immediately. - Active sibling tasks (which receive the
AbortSignalvia theirTask.Resultthunk) cancel promptly, preventing wasted compute and leaked connections. - Unstarted downstream nodes are immediately discarded.
- The caller task settles immediately with the failure.
Resilience Policies: Timeout & Retry
Section titled “Resilience Policies: Timeout & Retry”Individual nodes can attach resilience policies directly in their configuration:
timeout: Aborts the node if execution exceeds the specifiedDuration, returning the error produced byonTimeout(). The timeout error type is unified automatically into the node’s error channel.retry: Automatically retries transient failures according to the suppliedRetryPolicybefore reporting an error to the graph scheduler. The optionalwhenpredicate filters which errors trigger retries, whileonRetryintercepts individual retry events for logging or instrumentation.
Graph-Level Retry Telemetry
Section titled “Graph-Level Retry Telemetry”For centralized telemetry and tracing across all nodes in a graph, configure onRetry in
Graph.RunOptions:
Problems it solves
Section titled “Problems it solves”- Eliminating nested
Promise.allpyramids and positional tuple indexing: Asynchronous workflows with partial dependencies often degrade into brittle pyramids of nestedPromise.allcalls with positional destructuring ([res1, [res2, res3]]).Graphmodels workflows declaratively as named nodes and dependencies (deps: { user, config }), providing strongly typed dependency injection without positional coordinate tracking. - Preventing accidental step serialization: Developers frequently sequence independent steps
with sequential
awaitcalls to simplify data passing, unnecessarily inflating total request latency.Graphschedules all nodes with satisfied dependencies with maximum concurrency. - Guaranteeing single execution across diamond dependencies: When multiple downstream steps
share an upstream dependency, naive composition often executes the ancestor step redundantly for
each branch.
Graphcaches intermediate outputs and guarantees that shared nodes execute exactly once. - Immediate sibling cancellation on failure: When a node fails or throws, active in-flight
sibling tasks receive an
AbortSignalimmediately, terminating wasted compute, unreleased database connections, and outbound network calls promptly. - Pre-flight cyclic dependency detection: Accidental circular dependencies across modules or
services can cause silent runtime deadlocks.
Graph.makeruns Kahn’s algorithm at startup, failing fast with an explicit cycle error before execution begins. - Declarative resilience without timer boilerplate: Attaching timeouts or exponential backoff to
individual workflow tasks typically requires nested wrappers.
Graph.nodeacceptstimeoutandretrypolicies directly in the node configuration, automatically unifying timeout error types into the node’s error channel.