ReactiveDag.ScanRun (reactive_dag v0.17.0-rc.62)

Copy Markdown View Source

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

A scan is two phases that always happen together and were reported separately. The poll says what it fetched, what it could not reach and what it cost; the drain returns a ReactiveDag.Report saying what recomputed downstream. A host wanting "what happened when this ran" had to collect both from one telemetry payload and know which half answered which question.

This is the pair, named. ReactiveDag.ScanWorker builds one per scan and puts it on [:reactive_dag, :scan, :stop], so a broadcast, a durable row or a log line takes one value instead of reassembling it. ReactiveDag.Insights.record/1 retains it whole for the same reason — a host unwrapping to run.report throws away the poll, which is most of what the scan did and all of what it could not reach.

It does not merge the phases

Deliberately. A poll is fallible network I/O that must not abort a sweep when one upstream is down; a drain is pure recomputation that must RAISE rather than mark keys clean over work that did not happen. Those two rules cannot live in one loop, and nothing here tries to make them.

What was genuinely duplicated was the reporting: two vocabularies for "what did this cost", answered by ReactiveDag.Rollup for both. This is the same move one level up — one value for "what did this run do".

The propagation half is now separate

report is nil for every scan, and will stay that way. A poll used to drain in the same job, so a run genuinely was two phases; a poll now ENQUEUES a cascade per changed leaf and returns. The propagation happens in its own job, reports itself under [:reactive_dag, :cascade, :stop], and has no way to reach back into the scan that started it.

The field is kept rather than removed because a host may still populate it — a wrapper that runs a cascade synchronously for a test, say — and because removing it would break every host reading run.report for a nil check. There is no drained?/1: it answered "did the propagation run?", which became a constant false the moment a poll stopped propagating. A constant that reads as a question is worse than a missing function, so it is gone rather than deprecated — read the cascade's own report instead.

Summary

Types

t()

The poll half — what ReactiveDag.Source.refresh/3 returned, plus the cell it belongs to.

Functions

One cost key across both phases, summed per bucket.

Did the poll find anything?

Could the poll see everything it meant to?

Sum one cost key across BOTH phases.

Types

t()

@type t() :: %ReactiveDag.ScanRun{
  cell: String.t() | :sweep,
  changed: [String.t()],
  detail: map(),
  duration_us: non_neg_integer(),
  not_scannable: term() | nil,
  report: ReactiveDag.Report.t() | nil,
  unreachable: [{String.t(), term()}]
}

The poll half — what ReactiveDag.Source.refresh/3 returned, plus the cell it belongs to.

Functions

by(run, key)

@spec by(t(), atom()) :: %{optional(String.t() | atom()) => number()}

One cost key across both phases, summed per bucket.

ScanRun.by(run, :tokens_in)
#=> %{"claude-haiku-4-5" => 900, "openai/gpt-5.6-luna" => 3000}

The breakdown behind total/2, and the only form that answers "which model is driving this" when the poll and the drain use different ones — which is the normal case, since a classifier and a summariser are chosen separately.

changed?(scan_run)

@spec changed?(t()) :: boolean()

Did the poll find anything?

Distinct from propagation: a poll can change keys whose recompute produced nothing downstream, and a drain can run over marks another source left.

complete?(scan_run)

@spec complete?(t()) :: boolean()

Could the poll see everything it meant to?

The honest-gap discipline in one predicate: a scan that could not look must never render as a scan that found nothing.

total(run, key)

@spec total(t(), atom()) :: number()

Sum one cost key across BOTH phases.

ScanRun.total(run, :tokens_in)

This is the question a scan's cost line actually asks, and until now it took two calls and an addition: the crawl's own spend lives in detail and its downstream recomputes' spend lives in the report's steps. A crawler that classifies documents with a model and feeds nodes that summarise them with another spends in both places, and neither number alone is the bill.

ReactiveDag.Rollup does the arithmetic, so a flat count and a per-model breakdown both total, mixed freely across the two phases.