ReactiveDag.Insights (reactive_dag v0.17.0-rc.40)

Copy Markdown View Source

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

Everything here is a READ. Nothing new is computed — the plan already carries the structure and depths, each cell's own resource already carries its rows, and ReactiveDag.Drain.Report is already a complete causal trace. This module is those three assembled into the shape a human (or a dashboard, a mix task, an alerting check) actually asks for.

Per-cell state is read through ReactiveDag.Node.Rows, which reads the node's resource under the DAG's own cell keys. A node with no :status column reports its keys with a nil status — the count is still the truth about how many units the cell holds.

Deliberately UI-free: no Phoenix, no rendering, no assumptions about a web layer. reactive_dag_dashboard renders this; a host with no web layer at all can still call summary/1 from a mix task or a health check.

Retaining runs

record/1 keeps the last N runs in a rolling in-memory window, so a host gets a processing log without writing storage of its own. It takes what the engine hands back, in either of the two shapes the engine produces:

# a scan — the poll and the drain it triggered
:telemetry.attach("scans", [:reactive_dag, :scan, :stop], fn _e, _m, meta, _ ->
  ReactiveDag.Insights.record(meta.run)
end, nil)

# a drain someone triggered directly, with no poll in front of it
{:ok, report} = ReactiveDag.Drain.run(plan, opts)
ReactiveDag.Insights.record(report)

The buffer holds %ReactiveDag.ScanRun{} either way. A bare %Report{} is a drain with no poll around it, so it is wrapped in a run at the boundary rather than stored as a second shape — recent/1 returns ONE shape, and a consumer reads run.report for the drain and run.duration_us, run.changed, run.unreachable, run.detail for the poll without testing which kind of entry it got.

Why the whole run, and not just the drain

The buffer used to hold %Report{} alone, so a host with a %ScanRun{} unwrapped it and threw the envelope away. That discarded everything the POLL did: its wall time (usually most of a scan's — the drain is the cheap half), the keys it found changed, what it cost, and its unreachable list. A two-minute scan of an upstream it could not reach logged as 0 cells · 0 changed · 6.1ms, identical to a scan that looked at everything and found nothing. That is precisely the honest-gap failure ReactiveDag.Source warns about, reintroduced by the observer.

recent/1's entries therefore carry the run, and polled? says whether there was a poll at all, so "this drain reported no changes" and "this scan could not look" stay different sentences.

This is an opt-in observer, not a durable log: the buffer is per-BEAM-node, in-memory, and lost on restart. A host that needs history stores the run where its runs already live.

Summary

Types

A cell's observable state.

One retained run, as recent/1 returns it.

Functions

One cell's observable state: its declaration (depth, inputs, shape) plus what it currently holds (status histogram, key count, and a small sample of failing keys for the drawer).

The graph's edges as {from, to} pairs — an input edge points from the input cell TO the cell that reads it, i.e. the direction change flows.

Forget every retained run.

The most recent run, or nil if none has been recorded.

The graph's shape: cells grouped by depth, in execution order.

Cell ids with dirty keys waiting — what the NEXT drain would work on.

The most recent runs, newest first (default all retained).

Keep a run in the rolling in-memory window (default 20, configurable with config :reactive_dag, insights_keep: n).

Every cell's state, in execution order — the dashboard's landing view.

Types

cell_status()

@type cell_status() :: %{
  id: String.t(),
  depth: non_neg_integer(),
  inputs: [String.t()],
  leaf?: boolean(),
  op: atom() | nil,
  statuses: %{required(String.t() | nil) => non_neg_integer()},
  key_count: non_neg_integer(),
  rows: :stored | :elsewhere | :unreadable,
  failing_sample: [String.t()]
}

A cell's observable state.

entry()

@type entry() :: %{run: ReactiveDag.ScanRun.t(), at: DateTime.t(), polled?: boolean()}

One retained run, as recent/1 returns it.

  • run — the %ReactiveDag.ScanRun{}. Always present, always this struct, whichever shape was recorded.
  • at — when record/1 was called (wall clock, for ordering a log).
  • polled? — was there a poll? true for a scan, false for a bare drain recorded on its own. The one field that tells the two apart, so nothing has to infer it from a nil cell or an empty changed.

The drain is run.reportnil when a scan never drained (see ReactiveDag.ScanRun.drained?/1), so a consumer reading it must handle nil.

Functions

cell_status(plan, cell_id)

@spec cell_status(ReactiveDag.Plan.t(), String.t()) :: cell_status() | nil

One cell's observable state: its declaration (depth, inputs, shape) plus what it currently holds (status histogram, key count, and a small sample of failing keys for the drawer).

Reads the node's own resource, and pushes the reduction into the datastore: a histogram is a DISTINCT plus one COUNT per status, and the failing sample is a filtered LIMIT. No row is loaded to be counted, which matters most for a node whose payload is a blob — decoding it to discard it was the bulk of the old cost.

Still several small queries per cell, so summary/1 over a large graph is many round trips. That is the remaining cost, and it is a different one.

edges(plan)

@spec edges(ReactiveDag.Plan.t()) :: [{String.t(), String.t()}]

The graph's edges as {from, to} pairs — an input edge points from the input cell TO the cell that reads it, i.e. the direction change flows.

forget_runs()

@spec forget_runs() :: :ok

Forget every retained run.

last_run()

@spec last_run() :: entry() | nil

The most recent run, or nil if none has been recorded.

levels(plan)

@spec levels(ReactiveDag.Plan.t()) :: [{non_neg_integer(), [ReactiveDag.Cell.t()]}]

The graph's shape: cells grouped by depth, in execution order.

Depth is the longest path from a leaf, so every cell in a level can only depend on shallower ones — which is exactly the order the drain runs them in, and therefore the order worth drawing them in.

pending(plan)

@spec pending(ReactiveDag.Plan.t()) :: [String.t()]

Cell ids with dirty keys waiting — what the NEXT drain would work on.

Reads the frontier rather than the nodes themselves: a cell is pending because something dirtied it, whether or not its rows have changed yet.

recent(limit \\ :all)

@spec recent(pos_integer() | :all) :: [entry()]

The most recent runs, newest first (default all retained).

Each entry is a entry/0: the %ReactiveDag.ScanRun{}, when it was recorded, and whether a poll produced it.

for %{run: run, at: at, polled?: polled?} <- Insights.recent(20) do
  %{
    at: at,
    kind: if(polled?, do: :scan, else: :drain),
    # the whole run's wall time: a scan's poll AND its drain
    duration_us: run.duration_us,
    # the POLL. `cell` is what was scanned; the rest is what it found, what
    # it could not reach, and what it cost. All at their empty defaults on
    # a bare drain, where there was no poll to report them.
    scanned: run.cell,
    changed: length(run.changed),
    unreachable: run.unreachable,
    detail: run.detail,
    # the DRAIN — `report` is nil when a scan never drained, so guard it
    passes: run.report && run.report.passes,
    steps: (run.report && run.report.steps) || [],
    # …and either phase's cost, or both summed
    tokens_in: ReactiveDag.ScanRun.total(run, :tokens_in)
  }
end

run.duration_us is the WHOLE run — for a scan, the poll plus its drain. The drain's own share is run.report.duration_us, and the gap between the two is the poll, which is usually the larger number by orders of magnitude. Showing the drain's figure as the run's is what made a two-minute scan render as 6.1ms.

run.unreachable is [{upstream, reason}] — non-empty means the poll could NOT see everything it meant to (ReactiveDag.ScanRun.complete?/1), and a consumer that renders it the same as an empty one is reporting a gap as a clean run.

record(run)

Keep a run in the rolling in-memory window (default 20, configurable with config :reactive_dag, insights_keep: n).

Takes either shape the engine produces:

  • a %ReactiveDag.ScanRun{} — a scan, kept whole. The poll's duration, changed keys, cost detail and unreachable list are the point: they are most of what a scan did, and unwrapping to the report throws them away.
  • a bare %ReactiveDag.Drain.Report{} — a drain someone triggered directly, with no poll in front of it. Wrapped in a %ScanRun{} here so recent/1 returns one shape; polled? on the entry is false.

Opt-in: neither the scan nor the drain persists anything on its own. Returns its argument unchanged, so it drops into a pipeline either way:

plan |> ReactiveDag.Drain.run(opts) |> then(fn {:ok, r} -> Insights.record(r) end)

summary(plan)

@spec summary(ReactiveDag.Plan.t()) :: [cell_status()]

Every cell's state, in execution order — the dashboard's landing view.

One status query per cell. Fine for the graphs this library is built for (tens of cells); for a very large graph, page it with cell_status/2 instead.