A scanner — the third seam, alongside ReactiveDag.RecomputeStrategy and
ReactiveDag.KeyRule.
A source reads external state (a fleet API, a cloud estate, a repo, an LLM) and writes a leaf cell's rows in a poll phase deliberately OUTSIDE the drain:
- poll — run each source's
poll/1: fetch → write its leaf's rows → return the leaf keys that CHANGED (so the caller can mark parents dirty). Sources are independent; a failure is contained to its own leaf. - drain — the engine recomputes everything downstream from the dirty
frontier (
ReactiveDag.Drain). No source runs here.
This split is a design invariant, not an accident: the drain is pure set/graph computation over rows already written — deterministic, re-runnable, and it never fails on a network outage. Effectful, non-deterministic, fallible I/O (that's every scanner) stays in phase 1.
A source is a NODE. It declares poll MyApp.Sources.FleetScan in its
reactive block, and everything that reads it is an ordinary edge:
# the source — rows from outside the graph
reactive do
id :fleet
leaf? true
poll MyApp.Sources.FleetScan, every: "0 * * * *", args: [recent: true]
end
# a consumer — an ordinary node, reading it like any other input
reactive do
id :diggers
reduce over: :fleet, group_by: :key, expand: &diggers_only/2
endSo the cells a source feeds are its children: Source.cells_of/2 reads them
off the plan. There is one declaration and one place it is read.
It used to be two. A leaf declared scan Mod, the module declared
leaf_cells/1 back, and verify_scan!/3 raised when the copies disagreed —
an error class that existed only because the same fact was written twice, with
a message asking you to work out which side was stale. every: and args:
had the same problem one level up: spread across the leaves a source fed, they
had to be reassembled, and a source feeding two leaves could silently lose the
args one of them declared.
The contract
Two apps (a data pipeline and a compliance model) independently grew the same
shape — id / poll → changed-keys — which is why it lives here rather than
in either app.
defmodule MyApp.Sources.FleetScan do
@behaviour ReactiveDag.Source
@impl true
def id, do: :fleet_scan
@impl true
def poll(_opts) do
# fetch the fleet, write the source node's rows, return changed keys
{:ok, %{changed: ["host-1", "host-7"]}}
rescue
e -> {:error, Exception.message(e)}
end
endFan-out
One poll whose rows belong to several downstream nodes needs nothing special:
the source holds what it found, and each consumer projects its own part with
expand:, declining the keys that are not its own ({:skip, key} — see
ReactiveDag.Node). One fetch, one cadence, one set of standing args, and the
split is a declared property of the rows rather than a convention inside
poll/1.
Summary
Types
The lowered graph — a ReactiveDag.Plan (or any map with a :cells map keyed by id).
Callbacks
Stable id of this source (matches the source :id binding where declared).
Optional lineage for display: where this source's data comes from, as a map like
%{label: "Fleet · Huntress", url: "https://…", store: "Tigris"} (any subset).
Not implemented = origin unknown.
Poll the external source: fetch → write the leaf tuples → return the leaf keys that changed.
Functions
The cells a source feeds, derived from the graph.
What a host needs to render a scan control for each cell that has one:
%{cell_id => %{source:, args:, every:, origin:}}.
The Oban-style crontab entries a plan's leaves declare, as
{cron, worker, args: %{"cell" => id}}.
Poll every scanner the PLAN declares (via scan Mod on its leaves), in the
poll phase before a drain.
Poll the scanner feeding ONE cell — the "re-run this scanner" affordance.
Poll a cell's scanner AND mark what changed — the whole poll half of the two-phase loop, in one call.
Every scannable unit of work in the plan: %{cell:, source:, args:, every:}.
The distinct scanner modules a plan's leaves declare via scan.
The standing args: each scanner's leaf declared, as %{module => keyword}.
Verify a poll Mod declaration on cell_id: the module must be a loadable
ReactiveDag.Source.
Types
The lowered graph — a ReactiveDag.Plan (or any map with a :cells map keyed by id).
Callbacks
@callback id() :: atom()
Stable id of this source (matches the source :id binding where declared).
@callback origin() :: map() | nil
Optional lineage for display: where this source's data comes from, as a map like
%{label: "Fleet · Huntress", url: "https://…", store: "Tigris"} (any subset).
Not implemented = origin unknown.
@callback poll(arg :: term()) :: {:ok, [String.t()]} | {:ok, %{ :changed => [String.t()] | %{optional(String.t()) => [String.t()]}, optional(:unreachable) => [{String.t(), term()}], optional(:detail) => map() }} | {:error, term()}
Poll the external source: fetch → write the leaf tuples → return the leaf keys that changed.
Three shapes, in ascending order of how much they say:
{:ok, ["k1", "k2"]} the changed keys, bare
{:ok, %{changed: ["k1"]}} ...plus `unreachable` / `detail`
{:ok, %{changed: %{leaf => keys}}} one poll, several cellsThe bare list is the common case and belongs to the cell being polled. The
map form is what a scanner reaching several cells, or reporting an outage,
needs. {:error, reason} when it couldn't run at all (no credential,
API down) — contained, not raised, so one bad source doesn't abort a refresh.
arg is source-specific (a since-timestamp, a manifest path, opts).
A multi-upstream source that could observe SOME of its inputs reports the
others under the optional unreachable: key ({upstream_label, reason}
pairs) — the honest-gap discipline: a scan that couldn't look must never
render as a scan that found nothing, so write what you observed, retire
nothing you couldn't see, and surface the outage for the host to display.
Functions
The cells a source feeds, derived from the graph.
A node declares poll MyCrawler; everything reading that node is an ordinary
edge, so a source's outputs are its children. One declaration, read in one
place, and nothing that can go stale.
It used to be the reverse: the module declared leaf_cells/1, each fed leaf
declared scan, and verify_scan!/3 caught the two disagreeing. Every
implementation in this repo and its host apps ignored the graph argument and
returned a literal list, restating what the leaves had already said.
Returns [] for a source no node polls — not an error: a plan built from a
subset of resources legitimately excludes some.
What a host needs to render a scan control for each cell that has one:
%{cell_id => %{source:, args:, every:, origin:}}.
The library describes; the host renders. A cell with no scanner is absent, and
a scanner declaring no args:/every: reports them empty — so a leaf cheap
enough to run whole gets a plain "refresh" and no misleading range picker,
without the dashboard having to know which scanners are expensive.
origin: is the source's own origin/0 when it implements it, so a control
can say where it is about to fetch from.
The Oban-style crontab entries a plan's leaves declare, as
{cron, worker, args: %{"cell" => id}}.
The library never schedules anything. A leaf declaring every: states how
often a routine poll should run; this collects those declarations into data
the host hands to its own scheduler:
plugins: [
{Oban.Plugins.Cron, crontab: ReactiveDag.Source.crontab(plan, MyApp.ScanWorker)}
]Emitting data rather than inserting jobs keeps the library out of the host's supervision tree and out of its deploy story — and lets a host filter, rewrite or ignore the entries, which it could not do if they were already scheduled.
The worker receives %{"cell" => "agenda_docs"} and is expected to poll that
one leaf. A leaf declaring no every: contributes nothing, which is the
correct outcome for a scanner cheap enough to run on any cadence the host
likes.
One entry per SCANNER, not per leaf
A source feeding several leaves — one crawl of one site whose rows land in two
cells — is ONE unit of scheduled work. scan_jobs/1 lists it per-cell, which
is right for a control panel (a human triggers a leaf, and each leaf declares
its own bound), and wrong for a scheduler: two leaves declaring the same module
would emit two entries and crawl the same site twice an hour, each poll marking
both leaves and the second achieving nothing.
So entries are deduplicated by scanner. The "cell" carried is the first of
its leaves alphabetically — any of them reaches the same scanner, and
refresh/3 marks every leaf the poll reports regardless of which one was
named.
Two leaves declaring DIFFERENT cadences for one scanner get one entry each, on the assumption that a host writing two different cadences meant them; the duplicate crawl is then declared rather than accidental.
Adding your own arguments
A crontab entry is built once at config time, so anything it carries is fixed
for every firing — a per-run id has to be minted when the job fires, inside
the worker. What a host CAN do here is add its own standing arguments, with
args::
Source.crontab(plan, MyApp.ScanWorker, args: %{"queue" => "crawls"})merged UNDER the "cell" this computes, so a typo cannot silently retarget
the job at a different leaf.
One sweep, not N jobs
The default emits ONE entry per distinct cadence — a sweep. The job polls every source of that cadence sequentially, in graph order, then drains once.
That is the shape worth defaulting to, because N independent cron entries
cannot order themselves: they fire concurrently whatever order the list is in,
so a source that needs another to have run first has no way to say so. Inside
one job it does — depends_on on a source is a sequencing edge, and
scanners/2 sorts by depth.
# a source that must be polled after :observations
reactive do
id :verdicts
leaf? true
poll MyApp.Sources.Verdicts, every: "0 * * * *"
depends_on [:observations]
endper_cell: true opts back into one entry per source, for a host that wants
independent jobs — separate queues, separate retry policies — and accepts that
they are unordered. order: then applies to the emitted list, which is a
reading convenience rather than a happens-before. A host that wants per-firing values wraps the
worker rather than the crontab: mint the id in perform/1, then call
refresh/3 with reason: "scan:#{run_id}" — :reason is a free string and
rides through to the frontier rows, so the trace says which run dirtied a cell.
Poll every scanner the PLAN declares (via scan Mod on its leaves), in the
poll phase before a drain.
Scanners are found from the graph rather than a list the host maintains alongside it — the list is the thing that drifts. A source feeding many leaves appears once, however many leaves declare it.
Returns {:ok, %{module => result}}, or {:error, failures} where failures
are {module, reason}: one scanner failing must not silently cancel the
others, and must not look like success.
Ordering
Sources are polled SEQUENTIALLY, in cell order. A host whose sweep order carries meaning — observers before the nodes that read them — declares it:
Source.poll_all(plan, order: [:observations, :verdicts])Anything unlisted follows, in cell order. Unlike crontab/3's order:, this
one is a real happens-before: these polls run one after another in this
process, so a later source genuinely sees what an earlier one wrote.
Telemetry
[:reactive_dag, :scan, :source_stop] fires as each source finishes, with
%{source: module, result: result} — including the failures, since a source
that could not run is a thing a host wants recorded.
Sources are polled one at a time, so this doubles as PROGRESS: a sweep over eight sources can run for minutes, and without it the only observable moments are the beginning and the end of the whole run.
@spec poll_cell(graph(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} | {:error, :no_scanner}
Poll the scanner feeding ONE cell — the "re-run this scanner" affordance.
poll_all/2 is the routine sweep. This is what a host wires a button to: a
dashboard has a cell in hand, not a source module, and a human asking to
refresh is asking about this leaf, not about every scanner in the graph.
# routine, on the declared cadence
Source.poll_all(plan)
# a human pressed "refresh", accepting the cheap default
Source.poll_cell(plan, "agenda_docs")
# ...or asked for the deep pass
Source.poll_cell(plan, "agenda_docs", recent: false)The leaf's declared args: apply exactly as they do in poll_all/2, with the
caller's opts winning — so a button that passes nothing gets the cheap pass,
and one that passes recent: false gets the expensive one.
Returns {:ok, result}, {:error, reason} if the poll failed, or
{:error, :no_scanner} when the cell declares none — which a host should
render as "no refresh available" rather than as a failure.
Note a source feeding several leaves is polled whole: poll/1 takes options,
not a cell, so asking for one leaf runs whatever that scanner does. The
scanner narrows itself through args: if that matters.
@spec refresh(graph(), String.t(), keyword()) :: {:ok, %{changed: [String.t()], marked: map(), unreachable: list()}} | {:error, term()}
Poll a cell's scanner AND mark what changed — the whole poll half of the two-phase loop, in one call.
poll_cell/3 returns what the scanner said and stops, leaving every host to
hand-write the same three steps: normalise the return shape, mark the
frontier, propagate to parents. That loop is documented in the guide and was
provided nowhere, which is why every host grew a worker to hold it.
{:ok, result} = Source.refresh(plan, "agenda_docs")
#=> %{changed: ["a", "b"], marked: %{"agenda_docs" => ["a", "b"]},
# unreachable: []}Then drain — separately, and deliberately so. The poll/drain split is a design invariant (external I/O must not sit inside a depth-ordered recompute), and a host polling several sources usually wants ONE drain after all of them rather than one each.
Return shapes
A single-leaf source returns a flat key list; a fan-out source returns
%{leaf_id => keys}. Both are in the Source contract, so both are
normalised here rather than in each host.
:reason labels the frontier rows (default "scan"), which is what makes a
drain's trace say why a cell was dirty.
Nothing is marked for an unreachable upstream, because the scanner reported no keys for it — an outage propagates nothing, which is the honest gap holding by construction rather than by rule.
@spec scan_jobs(graph()) :: [ %{ cell: String.t(), source: module(), args: keyword(), every: String.t() | nil } ]
Every scannable unit of work in the plan: %{cell:, source:, args:, every:}.
One entry per cell that declares a scan, whether or not it declares a
cadence. This is the list a host schedules from, renders controls from, and
triggers ad-hoc runs from — crontab/2 is a projection of the subset that
declared every:.
Source.scan_jobs(plan)
#=> [%{cell: "agenda_docs", source: MyApp.Crawler,
# args: [recent: true], every: "0 * * * *"}]Keyed by CELL rather than by source, because a source feeding several leaves is several units of work — each with its own declared bound.
The distinct scanner modules a plan's leaves declare via scan.
The standing args: each scanner's leaf declared, as %{module => keyword}.
poll_all/2 merges these under the caller's opts. Exposed because a host
driving one scanner directly wants the same default rather than a second copy
of it.
Verify a poll Mod declaration on cell_id: the module must be a loadable
ReactiveDag.Source.
That is the whole check now. It used to also assert the module's own
leaf_cells/1 claimed this cell — a check that existed only because the
pairing was written twice, and whose error message asked you to work out which
copy was stale. The cells a source feeds are plan.parents[id], so there is
one declaration and nothing to disagree with.