Durable, graph-based workflow execution for Elixir — built for long-running, interruptible work like agentic LLM sessions, where a run may pause for an external resolution, survive a deploy, and resume exactly where it left off.
You describe a workflow as a graph document: nodes that do work, shared state fields they read and write, and edges (optionally guarded) that decide what runs next. Docket executes the graph in deterministic supersteps and commits a checkpoint at every durable transition.
Design lineage: Docket's execution model draws on Google's Pregel and LangGraph's Pregel runtime: the established bulk-synchronous pattern of computation separated by commit barriers. That model fits the BEAM naturally: Docket fans each superstep into isolated, monitored node processes, gathers their results at a mailbox barrier, and uses supervision for fault containment while PostgreSQL—not process identity—remains the recovery authority.
Features
- Durable supersteps — runs advance in Pregel-style plan/execute/commit steps; the claim-fenced run update, schedule change, and retained events commit together in one PostgreSQL transaction.
- External interrupts — a node parks its run as
:waitingwith no live process; when the outside world answers,resolve_interruptwrites the value and the run continues in the next superstep. - Crash-safe recovery — claim fencing admits exactly one durable winner per transition, and recovery reclaims persisted runs after a process or node failure without host-owned resume code.
- Graphs as data — an authored graph is a plain document that round-trips through any JSON codec; publishing stores an immutable, content-addressed version, and every run records the graph ID and hash it started from.
- Rich control flow — fan-out, fan-in barriers, guarded branches, and cycles with optional superstep bounds; node failures retry per node policy.
- Processless testing —
Docket.Test.run_inline/2exercises full graph semantics in a unit test with no processes and no PostgreSQL. - Multi-tenant fairness — optional tenant scoping with claim policies that admit due work breadth-first across tenants, so one tenant cannot starve another.
- Small core — the core depends only on
telemetry; the PostgreSQL backend compiles when the host already usesecto_sqlandpostgrex, and Docket takes no JSON dependency.
Docket guarantees one atomic durable winner for each committed run transition — and is explicit about where that guarantee ends. A node attempt that proposes a transition may execute more than once after a crash, timeout, or claim steal, even though only one proposal commits, so external effects need a cooperating idempotency scheme. Checkpoint observers, notifications, and telemetry are best effort. See the delivery and execution guarantees for the full matrix.
Requirements
- Elixir 1.18+.
- The graph core and
Docket.Testhave no database requirement. - The durable backend requires PostgreSQL 13 or newer and host-declared
ecto_sql ~> 3.10andpostgrex ~> 0.17dependencies.
Installation
The graph and execution core needs only Docket:
def deps do
[
{:docket, "~> 0.1.0"}
]
endThat is enough to build graphs and execute them processless with Docket.Test;
it installs no Ecto or PostgreSQL dependency. Durable operation adds the
optional database dependencies later in this guide.
Quickstart
Define a node — a module that declares its config schema and does one unit of work against the shared state:
defmodule MyApp.Nodes.ExcludeAllergens do
@behaviour Docket.Node
@impl true
def config_schema do
Docket.Schema.object(%{
"recipes" => Docket.Schema.string(required: true),
"avoid" => Docket.Schema.string(required: true),
"to" => Docket.Schema.string(required: true)
})
end
@impl true
def call(state, config, _context) do
avoid = MapSet.new(state[config["avoid"]])
safe_recipes =
Enum.reject(state[config["recipes"]], fn recipe ->
Enum.any?(recipe["allergens"], &MapSet.member?(avoid, &1))
end)
{:ok, %{config["to"] => safe_recipes}}
end
endBuild a graph document wiring the node between $start and $finish:
recipe_schema =
Docket.Schema.object(%{
"name" => Docket.Schema.string(required: true),
"allergens" => Docket.Schema.list(:string, required: true)
})
graph =
Docket.Graph.new!(id: "recipe_safety")
|> Docket.Graph.put_input!("recipes",
schema: Docket.Schema.list(recipe_schema),
required: true
)
|> Docket.Graph.put_input!("avoid", schema: {:list, :string}, required: true)
|> Docket.Graph.put_field!("safe_recipes", schema: Docket.Schema.list(recipe_schema))
|> Docket.Graph.put_node!("exclude_allergens",
implementation: MyApp.Nodes.ExcludeAllergens,
config: %{"recipes" => "recipes", "avoid" => "avoid", "to" => "safe_recipes"}
)
|> Docket.Graph.put_edge!("start_filter", from: "$start", to: "exclude_allergens")
|> Docket.Graph.put_edge!("filter_finish", from: "exclude_allergens", to: "$finish")
|> Docket.Graph.put_output!("safe_recipes", [])Run the graph processless, with no supervision tree or database:
input = %{
"recipes" => [
%{"name" => "Pesto pasta", "allergens" => ["dairy", "nuts"]},
%{"name" => "Salsa", "allergens" => []}
],
"avoid" => ["dairy"]
}
{:ok, run, checkpoints} = Docket.Test.run_inline(graph, input)
run.status #=> :done
run.output #=> %{"safe_recipes" => [%{"name" => "Salsa", "allergens" => []}]}
Enum.map(checkpoints, & &1.type)
#=> [:run_initialized, :step_committed, :run_completed]Durable PostgreSQL
PostgreSQL support is opt-in. Docket does not install Ecto or Postgrex for core-only applications. The application that owns the Repo should declare all three dependencies directly:
def deps do
[
{:docket, "~> 0.1.0"},
{:ecto_sql, "~> 3.10"},
{:postgrex, "~> 0.17"}
]
endInstall the tables through a host-owned migration:
mix deps.get
mix docket.gen.migration -r MyApp.Repo
mix ecto.migrate -r MyApp.Repo
Configure a tenantless, poll-only runtime with explicit retention:
defmodule MyApp.Docket do
use Docket,
tenant_mode: :none,
backend:
{Docket.Postgres,
repo: MyApp.Repo,
notifier: :none,
pruner: [
interval_ms: :timer.hours(1),
event_retention_ms: :timer.hours(24 * 30),
run_retention_ms: :timer.hours(24 * 90),
batch_size: 1_000
]}
end
children = [MyApp.Repo, MyApp.Docket]
Supervisor.start_link(children, strategy: :one_for_one)The generated migration delegates to Docket's pinned schema version; commit it with the application rather than running migrations at application startup.
Publish an immutable graph version, then start durable work from the returned reference:
{:ok, graph_ref} = MyApp.Docket.save_graph(graph)
{:ok, run} = MyApp.Docket.start_run(graph_ref, input)
{:ok, finished} = MyApp.Docket.await_run(run.id, timeout: 5_000)
finished.status #=> :done
finished.output #=> run.outputstart_run returns after the initialized run is durably committed;
production advancement is asynchronous, and await_run is a bounded
convenience for callers that need to wait until a run pauses or terminates.
The facade also provides paged run, event, and graph-version readers plus
fetch_run, inspect_run, cancel_run, and retry_poisoned_run; the
Docket module docs are the authoritative API reference.
Multi-tenant applications configure tenant_mode: :required with a fair
claim policy such as Docket.Postgres.ClaimPolicy.WindowedInterleave, and
durable integration tests use the production lifecycle through
testing: :inline or testing: :manual. See the
parent-application example and the
PostgreSQL operations guide.
External interrupts
A node pauses the run by returning an interrupt naming the state field where the external resolution should be written:
def call(state, _config, _context) do
case state["decision"] do
nil -> {:interrupt, %Docket.Interrupt{resume_channel: "decision"}}
decision -> {:ok, %{"applied" => decision}}
end
endThe run commits as :waiting without retaining a per-run process. When the
external system resolves it:
{:ok, run} =
MyApp.Docket.resolve_interrupt(run_id, interrupt_id, "approved")The value is validated against the interrupt's schema (if any), written to the resume field, and the interrupted node re-executes in the next superstep with the resolved value visible in its state.
Execution model
A run advances in Pregel-style supersteps:
- Plan — from committed state only, select the activated nodes and build their task descriptors (deterministic IDs and idempotency keys).
- Execute — dispatch each activation through the configured executor. Nodes see the same committed snapshot; nothing observes a same-step write.
- Commit — validate writes against field schemas, apply reducers, resolve same-step conflicts in sorted node order, evaluate edge guards and fan-in barriers, and commit the step atomically with a checkpoint.
Current boundaries
Docket deliberately leaves these to the host application:
- Authorization and ownership checks before calling a Docket facade.
- Graph publish workflows and application-level version selection.
- UI projections for editors and live run views.
- External effects — nodes call your code; Docket never talks to the network.
The configured backend exclusively owns graph/run persistence, scheduling, recovery, signals, and production supervision.
Learn more
- PostgreSQL operations and correctness guide — statuses, claims, poison recovery, configuration, testing modes, and inspection.
- Delivery and execution guarantees — the full guarantee matrix, partition behavior, and integration rules.
- examples/parent-app-integration.md — the durable parent-application integration boundary.
- examples/llm-node.md — a generic, configurable LLM node implementation.
- 0.0.1 to 0.1.0 migration guide.
- Backend test guide — the shared source test suite and backend-specific coverage boundary.
- Telemetry — operational signals and bounded labels.
- Module docs —
Docket,Docket.Graph,Docket.Run,Docket.Node,Docket.Checkpoint, andDocket.Testare the authoritative API reference.