API Reference reactive_dag v#0.17.0-rc.40

Copy Markdown View Source

Modules

A content-addressed digest of a row set — what those rows looked like at a moment, so a later comparison can tell whether they have moved.

Calendar bucketing as an ASH CALCULATION — the Ash-native answer to the classic "date-marked records → time-bucketed aggregate". The bucket is declared on the resource that OWNS the date (where derived values live in Ash), and a rollup node just groups by the calculation

One node in the DAG — the domain-neutral IR both host apps compile down to.

Boot-time validation of config :reactive_dag, ….

Raised by ReactiveDag.Config.validate!/0 when configuration is wrong.

The reactive propagation loop — the heart of the substrate, shared by both hosts.

What a drain ACTUALLY did — the processing trace, returned by ReactiveDag.Drain.run/2.

The drain exceeded its pass budget — likely a cycle, or a recompute that keeps re-dirtying its own inputs. :report carries the PARTIAL trace up to the abort: report.steps' tail shows exactly which cells keep triggering each other, which is the diagnostic for the loop this error suspects.

The Oban job that drains the frontier and nothing else.

The DSL compile pipeline over the flat Cell IR — the resolve→lower→validate machinery both host DSLs share, parameterized by app hooks so each keeps its own domain vocabulary.

The dirty frontier, owned by the library and backed by the reactive_dag_dirty table (created by ReactiveDag.Migration). The host is an Ash/AshPostgres app, so we go through its repo with raw SQL — values always parameterized; the table name (the one identifier SQL cannot parameterize) comes from config and is validated against an identifier grammar at read time, so a typo fails loudly instead of as a syntax error deep in a query. Claim-as-delete is a raw DELETE … RETURNING that Ash actions don't express cleanly.

Pure DAG construction: a list of ReactiveDag.Cell → a ReactiveDag.Plan.

The engine, viewed from outside: what the graph LOOKS like, what state each cell is in, and what the last drains actually did.

What every library-provided Oban job needs from its arguments.

The vocabulary of propagation: when changed keys of a child feed a parent, which of the parent's keys become dirty?

Shared machinery for lowering a NESTED op-expression into a flat cell list — the recursion both host DSLs independently grew (cascade's Lower.resolve_legs, the portal's Graph.build_node). Same algorithm: walk an op's legs, recurse into each, a ref resolves to an existing cell-id (no new cell), a nested op becomes an intermediate cell whose inputs are the recursed leg ids.

The library-owned DDL, callable from a host migration — the dirty-frontier table ReactiveDag.Frontier reads and writes (coalesced by (cell_id, key); claim is a DELETE … RETURNING)

An Ash resource extension that makes a resource a node in a reactive DAG. The resource IS the node and its own payload table: its reactive block defines the computation, its attributes are the rows the node materializes. This is the intended shape — one resource, both roles.

A PURE-ASH-QUERY reduce: the datastore does the grouping via a RELATIONSHIP aggregate. The node's own resource is the group's resource — ONE row per group — and over names its has_many to the rows being aggregated. The library loads the aggregates in ONE Ash query — Postgres computes the GROUP BY — and each parent row's aggregate values are its payload. No rows cross into the BEAM; no into/read/upsert (contrast the in-BEAM reduce, which loads every row).

Marks a written record's key dirty on its own cell, so the next drain picks the change up. Wired automatically by dirties_on — hosts do not add it.

An anonymous nested op-expression leg: composes inline as an intermediate cell (its as id, or a positional id derived from the parent). Its own legs are ref/compose, so the algebra reads as an expression tree.

The ESCAPE HATCH: declare an arbitrary recompute MODULE (a ReactiveDag.Op) for a node whose computation the reduce/join combinators can't express — an LLM call, a PDF/Tigris fetch, a bespoke multi-input recompute. compute MyApp.EventsExtract sits in the block alongside the combinators, mirroring Ash's calculate :x, :type, MyModule (the arbitrary case is an entity too, not a schema key beside the declarative ones).

A by-name CONTEXT input edge (context :people): the node READS the target as settled context but is NOT recomputed when the target changes. Still a real input (validated, ordered by depth so the target settles first, read at recompute) — it just doesn't propagate. For a node whose recompute is expensive/non-deterministic and consults mutable context it shouldn't be re-triggered by (an LLM step that looks up a human-curated people/positions table). Contrast ref, which dirties this node on change.

The one value that decides whether an observation MOVED.

A declarative JOIN: read ONE input's payload, index it into a LEFT and a RIGHT side (each a %{join_key => item} built from a per-side key fn), then emit one row per left key joined to its right item (right may be absent). The common declared-vs-observed reconcile/variance shape — the author writes the two side keys + the join row, not the read/write/changed plumbing.

THE propagation rule: how a change reaches a parent, decided by what the parent DECLARED. ReactiveDag.Drain calls this — there is nothing to configure.

What a machine recompute does to a HUMAN's mark: lapse :approved_at, when_changed: :any.

Closes the payload loop for a resource-backed node: writes a combinator's output row into the node's OWN resource (cell.meta.resource), keyed by the cell key.

The PER-ENTRY MAP: for each claimed row of the input, call a generic action with that row and write its structured output into this node's attributes.

This node's rows come from OUTSIDE the graph: poll MuniWatch.Crawler.

THE engine: how a cell recomputes, decided by what its node DECLARED.

Runs a pure-Ash-query aggregate node: the datastore groups + aggregates the node's over relationship in ONE query (a relationship aggregate — Postgres does the GROUP BY), and each parent row's aggregate values become its payload.

The pure builders behind the DECLARATIVE combinator slots. Each turns a declarative spec — attribute atoms, fold keywords, side picks — into the same fn shape the per-slot escape hatches supply, so the recompute pipeline runs one code path however declarative the author went.

Runs a per_key node: for each claimed input row, call a generic action with that row and write its structured output into this node's attributes.

Executes a combinator's READ — always an Ash read of the over node's resource (its primary read action, or the :read action read: names), shaped by the optional query: transformer, and ALWAYS scoped by the library: when the recompute claimed specific dirty keys, the over's payload key is filtered to them (the transformer cannot un-scope; scoping is the substrate's correctness concern, not policy).

Runs a union node: one row per (input cell, key) across several inputs, written into this node's own table.

THE declaration the engine cares about: what unit does a change invalidate? Everything else a combinator declares — group_by, into, key derivation — is mapping data into shape once you already know what to recompute.

A declarative REDUCE (fold): read an input node's payload, group it, and reduce each group to one output row — the common map/fold shape, so the author writes the grouping + reduction, not the read/write/changed plumbing. Anything the combinator can't express (an LLM call, an external fetch, a bespoke join) uses the compute: module escape hatch instead.

A by-name input edge to another named node (ref :id). The general form — nestable inside compose. The flat depends_on: [:a, :b] schema key is sugar that lowers to one %Ref{} per id.

Reads a cell's own rows, keyed the way the DAG keys them.

The ASH-NATIVE escape hatch: run :recompute_keys declares that this node's recompute is a GENERIC action on its own resource — one step less escape than a compute module, because the computation stays a first-class Ash action (arguments, policies, Ash.run_action testability).

A dimension a human may select this node by: slice :fiscal_year.

Wires dirties_on and augmented_by onto ReactiveDag.Node.Changes.MarkDirty, so ordinary Ash writes trigger the cascade with no host boilerplate.

The N-INPUT shape: one row per (input cell, key) across several inputs, materialised into this node's own table.

Compile-time checks for the reactive block — everything verifiable against the node's OWN resource fails at defmodule, not at drain time. (Checks that need the OVER node's resource — named read actions, attribute existence — run at graph assembly instead: that is the earliest point cross-node facts are known.)

The behaviour a node's compute module implements — the recompute for ONE op, the per-cell unit of work.

The compiled DAG plan — pure data the drain executes. Decoupled from any DSL: a host app lowers its declarations into a Cell list and Graph.build/1 produces this. The drain only ever sees the Plan.

Re-derive a cell's rows without any input having changed — the code moved, not the data.

Summing one key across many meta maps — the arithmetic behind every "what did this cost" line, wherever the numbers came from.

What one scan DID — the poll and the drain it triggered, as a single value.

The Oban job that polls one scanner and drains what it changed.

A scanner — how the world gets in.

A cell's live verdict: the one-word answer, rolled up from its rows' statuses.