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
dirty_table: "my_dirty" # the frontier (optional; defaults silently)Only :repo is required. Every key the library reads — with its default, what
reads it, and when you would change it — is in
Configuration.
dirty_table defaults silently to reactive_dag_dirty; a name that doesn't
match your migration yields empty results with no error, so set it explicitly if
you are adopting an existing table.
Migrations
The library owns exactly one table — the dirty frontier — and offers its DDL:
defmodule MyApp.Repo.Migrations.AddReactiveDag do
use Ecto.Migration
def up, do: ReactiveDag.Migration.up()
def down, do: ReactiveDag.Migration.down()
endIt resolves 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 cannot silently diverge.
Hand-write it only if you want it beside your own migrations:
def change do
# 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
add :prior, :map
end
# UNIQUE is load-bearing: mark_dirty coalesces via ON CONFLICT (cell_id, key).
create unique_index(:my_dirty, [:cell_id, :key])
endEverything else is your own resources. A node's results live in the node's resource, with that resource's migration — there is no second table shadowing them, and nothing to keep in sync.
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
# Ash-first: the library reads :fiscal_lines itself (dirty-key scoped),
# groups by the attribute, folds each group, derives the key ("gf"), and
# writes the row into THIS resource with change detection. No fns.
reduce over: :fiscal_lines,
group_by: :fund,
into: [sum: [amount: :total]]
end
endThe resource is the node and its payload table — and the computation is declared, not coded: no read plumbing, no write plumbing, no key derivation to author. When a shape outgrows attributes, each slot has an escape hatch. 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, attach to the [:reactive_dag, :drain, :step] telemetry event — see
ReactiveDag.Drain for the full event list.
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
A leaf is an ordinary resource. Data enters by writing rows to it, and the cascade starts when those writes are marked dirty.
The simple case — dirties_on. Declare it on the leaf and ordinary Ash
writes trigger the cascade themselves:
reactive do
id :fiscal_lines
leaf? true
dirties_on [:create, :update, :destroy]
endNow Ash.create!/1 on that resource marks its key dirty inside the write's
transaction — a rolled-back write leaves no dirty key, a committed one always
leaves one. Nothing else to call:
MyApp.FiscalLines |> Ash.Changeset.for_create(:create, attrs) |> Ash.create!()
ReactiveDag.Drain.run(plan, recompute: ..., key_rule: ...)The scanner case. When the data comes from outside — a fleet API, a repo, an LLM — the fetch is effectful and fallible, so it stays outside the drain. Write the leaf's rows, then mark what changed:
# 1. write the leaf's rows (your resource, your upsert)
# 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
A node's results are its own rows, so the first answer is an ordinary Ash read — with policies, filters, loads and joins:
MyApp.BudgetRollups |> Ash.Query.filter(status == "failing") |> Ash.read!()When you want the same rows addressed by cell key rather than by the resource's primary key — which is how the DAG talks about them — go through the cell:
cell = plan.cells["budget_rollups"]
ReactiveDag.Node.Rows.all(cell) # [%{key:, status:, record:}]
ReactiveDag.Node.Rows.status_histogram(cell) # %{"present" => 12}
ReactiveDag.Verdict.for_cell(cell) # a rolled verdict + failing sample
ReactiveDag.Insights.cell_status(plan, "budget_rollups")The coordination spine is not in this path. It records which keys a cell holds — for a source-fed leaf's reconcile, and for a node that writes its rows elsewhere; results live where you can query them.
Where next
- Authoring nodes — every node shape and combinator.
- Sources and scanning — the poll/drain split and the honest-gap discipline.
- The seams — custom recompute strategies, key rules, extension columns, and hand-assembled graphs.