ReactiveDag.Node.Rows (reactive_dag v0.17.0-rc.6)

Copy Markdown View Source

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

A node's rows live in its resource, but the DAG addresses them by cell key — a "|"-joined identity for a composite-PK node, a single column's value otherwise. Anything that wants to ask a question about a cell rather than about a table (ReactiveDag.Insights, ReactiveDag.Verdict, a union reading its inputs) needs the rows under those keys, not under the resource's own primary key.

This is the read side of what ReactiveDag.Node.Payload writes, and it deliberately mirrors that module's key derivation: identity_fields when the node is identity-keyed, payload_key (defaulting to :key) otherwise.

Why it is not the coordination tuple

These reads used to go to reactive_dag_tuple, which carried a status per (cell_id, key). That made the tuple a second home for derived results — one with a fixed two-column schema, updated by a writer the host had to configure, and queryable only through this library. Reading the resource instead means a status is an ordinary column with ordinary Ash semantics: policies apply, loads work, and a host can add a second column without asking us.

Missing columns are not errors

A node need not have a :status column — most don't; a rollup has sums. Rows from such a node come back with status: nil, and callers that count statuses simply find nothing to count. A node with no attributes at all keeps its rows somewhere else entirely and reads as empty. Asking is always safe.

Summary

Types

one of a cell's rows, addressed by cell key

Where a node's rows live and how they are keyed — a cell's meta, or the same three fields lifted out of it (which is what a union carries for each of its inputs, since it reads rows it does not own).

Functions

Every row the cell currently holds, as %{key:, status:, record:}.

The keys whose status is in statuses, at most limit of them (sorted, so a sample is stable between calls rather than reshuffling on every render).

Reconcile a leaf's rows against the key set a scan found — the algorithm every leaf driver otherwise hand-rolls.

%{status => count} over the cell's rows — the histogram Insights shows and Verdict folds into one answer.

Types

row()

@type row() :: %{key: String.t(), status: String.t() | nil, record: struct()}

one of a cell's rows, addressed by cell key

source()

@type source() :: %{
  optional(:resource) => module() | nil,
  optional(:payload_key) => atom() | nil,
  optional(:identity_fields) => [atom()] | nil
}

Where a node's rows live and how they are keyed — a cell's meta, or the same three fields lifted out of it (which is what a union carries for each of its inputs, since it reads rows it does not own).

Functions

all(source)

@spec all(ReactiveDag.Cell.t() | source()) :: [row()]

Every row the cell currently holds, as %{key:, status:, record:}.

Returns [] for a node that keeps no rows here — no resource at all, or a resource with no attributes (the shape a compute/custom-upsert: node has, where the real writes land somewhere this library never sees). That is different from "holds nothing", so a caller that must tell the two apart should check meta[:resource] itself.

Raises whatever the underlying Ash.read!/1 raises. Callers on a display path (Insights) already run these behind their own rescue; a caller on a compute path wants the failure.

keys_by_status(cell, statuses, opts \\ [])

@spec keys_by_status(ReactiveDag.Cell.t() | source(), [String.t() | nil], keyword()) ::
  [String.t()]

The keys whose status is in statuses, at most limit of them (sorted, so a sample is stable between calls rather than reshuffling on every render).

reconcile(cell, want_keys, opts)

@spec reconcile(ReactiveDag.Cell.t(), [String.t()] | MapSet.t(), keyword()) ::
  {:ok, [String.t()]}

Reconcile a leaf's rows against the key set a scan found — the algorithm every leaf driver otherwise hand-rolls.

current  = the cell's current keys (read from its resource)
want     = `want_keys`, what the scan found
upsert   each want key    the host writes the row, returns true iff CHANGED
vanished = current  want  retired
 changed_upserts ++ vanished    (the keys to propagate)

:upsert is called once per want-key, in either of two forms:

  • (key -> row | nil) — the common case. Return the row you observed and the library writes it, deciding changed? against the node's declared fingerprint (or every attribute, if it declares none). Return nil for a key you could not observe: nothing is written and the key is not reported, which is how a partial outage stays honest.

  • (key -> boolean) — full control. Write the row yourself and say whether it moved. Use this when the write is not an upsert into the node's own resource.
  • :retire — how vanished keys leave. Defaults to destroying the row via the node's payload_destroy action; a host with a retain-if-vanish policy passes a (keys -> any) fun and marks its own rows instead.
  • :current — the baseline vanished is computed against. Defaults to the cell's current keys. A host whose live set is narrower than all its rows passes it explicitly — a retain-if-vanish leaf passes its non-tombstoned keys, so already-retired keys are neither re-retired nor reported as newly vanished.

Vanished keys always propagate: something disappearing is a change.

A leaf declaring fingerprint needs no :upsert at all for the row-returning form to be worth using — that is the point: poll/1 becomes fetch, build rows, call reconcile.

The honest gap

Call this only with a want-set you actually observed. An upstream you could not reach must write NOTHING — handing an empty want_keys to a scan that failed retires every key the cell has, and a downstream rollup over an empty set typically reads as vacuously fine. A scan that couldn't look must never render as a scan that found nothing.

status_histogram(cell)

@spec status_histogram(ReactiveDag.Cell.t() | source()) :: %{
  required(String.t() | nil) => non_neg_integer()
}

%{status => count} over the cell's rows — the histogram Insights shows and Verdict folds into one answer.

Rows with no status are counted under nil, so the counts always sum to the cell's key count and a node without a :status column reports %{nil => n} rather than lying with %{}.