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
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
@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
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.
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.
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.
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.