reactive_dag turns a set of Ash resources into an incrementally-recomputed
dependency graph: leaves are fed by scanners or humans, derived cells recompute
when — and only when — something beneath them changed, and every cell's result
is readable through one shared coordination table.
The library owns the schedule (what is dirty, what recomputes, in what order); your app owns the meaning (what an op computes, what a status string says, what the keys are). This guide takes you from an empty host app to a running two-cell graph.
Installation
# mix.exs
defp deps do
[
# pin the latest tag — https://github.com/u2i/reactive_dag/tags
{:reactive_dag, git: "git@github.com:u2i/reactive_dag.git", tag: "v0.16.0"}
]
endThe library assumes an Ash 3.x / AshPostgres host — it is an Ash extension, not a standalone framework.
Configuration
config :reactive_dag,
repo: MyApp.Repo, # REQUIRED — raises if unset
tuple_table: "my_tuple", # the coordination spine
dirty_table: "my_dirty", # the frontier
coordination_writer: MyApp.Writer # OPTIONAL — a spine-only default shipstuple_table / dirty_table default silently (reactive_dag_tuple /
reactive_dag_dirty); a name that doesn't match your migration yields empty
results with no error, so set them explicitly.
Migrations (the host owns the tuple; the lib offers the frontier)
The library defines the columns it needs and is their only reader/writer, but the physical tables live in your migrations — that's what lets a host add its own extension columns beside the spine.
The dirty frontier has no extension columns, so the library can own its DDL —
ReactiveDag.Migration.up/1 / down/1 create it for you, resolving the table
name exactly as the runtime does (config :reactive_dag, dirty_table:, with an
explicit dirty_table: option overriding), so the migrated table and the
queried table can't silently diverge:
defmodule MyApp.Repo.Migrations.AddReactiveDag do
use Ecto.Migration
def up, do: ReactiveDag.Migration.up()
def down, do: ReactiveDag.Migration.down()
endHand-write it only if you want to co-locate it with the tuple migration, as below. The TUPLE table stays yours either way (extension columns).
def change do
# the coordination tuple: one row per (cell, key), carrying the verdict +
# freshness. A cell IS its set of these rows.
create table(:my_tuple, primary_key: false) do
add :cell_id, :string, null: false
add :key, :string, null: false
add :status, :string, null: false, default: "present"
add :observed_at, :utc_datetime_usec
add :stale_after, :utc_datetime_usec
add :updated_at, :utc_datetime_usec
# ... your extension columns here (strength, source_ref, …) — the library
# neither reads nor writes them; see the "Seams" guide.
end
create unique_index(:my_tuple, [:cell_id, :key])
create index(:my_tuple, [:cell_id, :status])
# the dirty frontier: pending recompute work, claimed-as-deleted by the drain.
create table(:my_dirty, primary_key: false) do
add :cell_id, :string, null: false
add :key, :string, null: false
add :reason, :string
add :enqueued_at, :utc_datetime_usec
end
# UNIQUE is load-bearing: mark_dirty coalesces via ON CONFLICT (cell_id, key).
create unique_index(:my_dirty, [:cell_id, :key])
end(If you use attestations, the record store is different: it is an Ash resource you define, with generated migrations — see the Attestations guide.)
A first graph
Two nodes: a leaf fed from outside, and a derived rollup over it.
defmodule MyApp.FiscalLines do
use Ash.Resource, data_layer: Ash.DataLayer.Simple, extensions: [ReactiveDag.Node]
reactive do
op :source
leaf? true # fed by a source, never recomputed by the drain
end
end
defmodule MyApp.BudgetRollups do
use Ash.Resource,
data_layer: AshPostgres.DataLayer, # this node's OWN payload table
extensions: [ReactiveDag.Node]
attributes do
attribute :key, :string, primary_key?: true
attribute :fund, :string
attribute :total, :float
end
actions do
create :upsert do
upsert? true
upsert_identity :key
accept [:key, :fund, :total]
end
end
reactive do
op :fold
key_rule :all
reduce over: :fiscal_lines,
read: fn :fiscal_lines -> MyApp.Fiscal.lines!() end,
group_by: fn line -> line.fund end,
key: fn fund -> fund end,
into: fn fund, lines -> %{key: fund, fund: fund, total: sum(lines)} end
end
endThe resource is the node and its payload table: reduce's into returns
a row, and the library writes it into this resource with change detection — no
write plumbing to author. See Authoring nodes for every
node shape.
Assemble and run
plan = ReactiveDag.Node.graph([MyApp.FiscalLines, MyApp.BudgetRollups])
{:ok, report} =
ReactiveDag.Drain.run(plan,
recompute: ReactiveDag.Node.Recompute, # dispatches reduce/join/aggregate/compute
key_rule: ReactiveDag.Node.KeyRule # reads :identity | :all off the block
)
report.steps
# one entry per cell recompute, in execution order:
# %{cell: "budget_rollups", triggered_by: "fiscal_lines",
# claimed: ["fy24"], changed: ["fy24"], pass: 1, duration_us: 812}The %ReactiveDag.Drain.Report{} is the drain's processing trace — what ran,
why (triggered_by reconstructs the causal tree), what actually changed, and
how long each step took. Persist it wherever your runs live (a job's meta, a
run table): the library reports, the host records. For progress during a
long drain, pass on_step: fn cell, step -> ... end.
graph/2 validates the whole thing at assembly: every edge resolves, ids are
unique, the graph is acyclic — an authoring mistake fails loudly here, not
silently at runtime.
The drain is incremental: it processes only cells with dirty keys, in dependency (depth) order, and propagates only the keys each recompute reports as actually changed. An empty frontier is a no-op.
Feeding the leaf
Data enters through a source — anything that writes a leaf's tuples and marks its parents dirty. The typical shape:
# 1. write the leaf's tuples (reconcile computes what changed/vanished)
{:ok, changed} =
ReactiveDag.Tuple.reconcile("fiscal_lines", keys,
upsert: fn key -> ReactiveDag.Tuple.put("fiscal_lines", key) == :ok end
)
# 2. mark the changed keys dirty upward
ReactiveDag.Graph.dirty_parents(plan, "fiscal_lines", changed, ReactiveDag.Node.KeyRule)
# 3. drain
ReactiveDag.Drain.run(plan, recompute: ..., key_rule: ...)For a scanner with a real contract (id, leaf binding, failure containment),
implement ReactiveDag.Source — see Sources and scanning, which
also covers the one discipline that matters most: an unreachable upstream
writes nothing, because an estate you could not survey must not render as an
empty estate.
Reading results
ReactiveDag.Tuple.status_histogram("budget_rollups") # %{"present" => 12}
ReactiveDag.Tuple.rows("budget_rollups") # [%{key:, status:, observed_at:}]
ReactiveDag.Verdict.for_cell("budget_rollups") # a rolled verdict + failing samplePayload (the typed values) stays in each node's own resource; the tuple carries
only the verdict and freshness, joined back by key.
Where next
- Authoring nodes — every node shape and combinator.
- Sources and scanning — the poll/drain split and the honest-gap discipline.
- Attestations — human sign-off as a first-class input.
- The seams — custom recompute strategies, key rules, extension columns, and hand-assembled graphs.