blockwatcher-core
blockwatcher-core is blockwatcher’s engine: it assembles a running pipeline out of a
network’s already-constructed source,
its chain’s decoder, and the
engine’s one matcher; tracks every
dispatched match until it is either
delivered or dead-lettered;
decides the checkpoint that
tracking makes safe to persist; and is the one path (ControlHandle) by
which a resource write, delete, pause, dry run, or dead-letter replay reaches
a running deployment. It is the largest crate in the workspace, and this is
its largest page.
Its production dependencies are exactly the names
scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_CORE entry lists:
blockwatcher-types: the vocabulary every resource, event, and identifier this crate moves is expressed inblockwatcher-ports: the port traitsspawn’s tasks drive (Source,Decoder,Matcher,Gate,Sink,Storage)serde: the derive machinery behind every (de)serializable shape in the crateserde_json: every resource’s on-the-wire and in-storage JSON representationthiserror: the derive behindEngineError(error.rs)tokio(macros/rt/timefeatures): the async runtime every task, channel, and timer in the crate runs ontokio-util:CancellationToken, the primitivespawn’s whole cancellation topology (cancel,hard_cancel,drain_token) is built fromfutures:FutureExt::catch_unwind, used by the source task wrapper to catch a panicking source without taking the pipeline down with itmetrics: the facade every named constant inmetrics.rsemits throughtracing: every structured log line in the crate
No chain SDK, HTTP client, or storage driver appears anywhere in this list
or its transitive tree. blockwatcher-core also carries no family exemption at
all in that same script’s family_exemptions_for
(family_exemptions_for in scripts/check-dep-graph.sh): nothing forbidden may appear anywhere
in its transitive tree under any named carve-out. See Architecture
decisions § Chain knowledge stays out of the
core and
Workspace map § Verifying the
rings for the rule this crate is one
example of.
The pipeline and Delivery
guarantees already describe this crate’s
pipeline at an operator’s level: one task per stage, the backpressure it
enforces, the guarantee a checkpoint carries. This page goes one level
deeper: every file inside pipeline/ and control/ mapped to what it
owns, the exact channel and cancellation-token wiring spawn builds, and the
checkpoint writer’s own retry state machine. Where the two levels overlap,
this page links to the concept page rather than restating it.
Key takeaways
blockwatcher-coreis the engine: it assembles one pipeline per network from a source, a decoder, the one matcher, and an optional per-monitor gate, and is the only path (ControlHandle) through which a resource write reaches a running deployment.- No chain SDK, HTTP client, or storage driver appears anywhere in its dependency tree, and it carries no family exemption at all.
- It is the largest crate in the workspace, depending only on
blockwatcher-typesandblockwatcher-portsamong workspace crates. - This page goes one level deeper than the pipeline and delivery-guarantees
concept pages: every file inside
pipeline/andcontrol/, the exact channel and cancellation wiringspawnbuilds, and the checkpoint writer’s own retry state machine.
Responsibilities
- Assembles and runs one pipeline per network: a single function,
pipeline::spawn, constructs every channel and cancellation token a pipeline needs and then starts a source task, a processor task, one sink worker task per sink any of that network’s monitors reference, and a checkpoint writer task on top of them (crates/blockwatcher-core/src/pipeline/mod.rs). See Pipeline workers below for how every file inpipeline/maps to one of those tasks. - Owns the whole control plane:
Engine::start/Engine::shutdownfor boot validation and a deadline-bounded drain, andControlHandlefor every resource write, delete, pause/resume, dry run, checkpoint skip, and dead-letter replay a running deployment accepts (engine.rs,control/). See The control plane. - Tracks every dispatched match until it is delivered or dead-lettered, and
derives from that the one cursor a network’s pipeline may safely persist,
through
Progress/CompletionGuard(progress.rs) andCheckpointWriter(pipeline/checkpoint_writer.rs). See Progress and checkpointing. - Compiles a monitor’s selectors and predicate, and a chain’s specs, against
the live decoder and the engine’s one matcher instance, once at write
time, never on the event path (
compile.rs,compiled.rs). - Names every module family a config can select from and reports an
actionable refusal, listing every registered alternative, for a name
nothing registered (
catalog.rs). - Fans every pipeline event out to two independent observability
destinations: the atomic
PipelineCountersa status read serves with no cardinality cost (counters.rs), and themetricsfacade constants an exporter may or may not be listening to (metrics.rs). See blockwatcher-metrics for the side of that pair which actually exports anything. - Reads and type-validates every stored resource before anything downstream
ever sees it (
resources.rs), and reports a point-in-time snapshot of every running, paused, or abandoned pipeline (status.rs).
Not this crate’s job: deciding what a chain’s wire format means, or
implementing any source, decoder, sink, or storage module: blockwatcher-evm,
blockwatcher-storage, and blockwatcher-sinks each own exactly one, behind the port
traits blockwatcher-ports declares; running the predicate language itself
(blockwatcher-expr: this crate only calls Matcher::compile/matches through
the port); exposing anything over HTTP (blockwatcher-api wraps ControlHandle in
routes) or Prometheus (blockwatcher-metrics installs the recorder this crate’s
metrics.rs module writes into, and serves the scrape endpoint); defining
the port traits it drives or the vocabulary types it moves (blockwatcher-ports,
blockwatcher-types).
Key types and traits
| Name | Kind | Role |
|---|---|---|
Engine, EngineDeps | struct | The running engine: one pipeline per network, boot validation, and a deadline-bounded shutdown drain; EngineDeps is what Engine::start takes by value (storage, module catalog, config) |
Engine::start, Engine::shutdown, ShutdownReport | fn / struct | Boot’s all-or-nothing validate-then-spawn pass, and shutdown’s per-pipeline drain report (drained vs. aborted) |
ControlHandle | struct | The one path a running engine’s resources change through; every mutating method holds Engine’s single mutation lock for its entire duration |
EngineError | enum | Every refusal the engine or a mutation can produce, each variant carrying the identity of what it happened to |
EngineConfig, SourceRestart | struct | Boot-time instance configuration (channel capacities, drain deadline, journal_depth, default retry policy) and the restart supervisor’s backoff policy |
ModuleCatalog | struct | Name-keyed home for every module family’s factories; a name nothing registered fails as UnknownModule, listing every alternative that was |
Resources, Versioned<T> | struct | Typed, validated reads over the primitive Storage port; Versioned pairs a value with the optimistic-concurrency version it was read at |
CompiledMonitor, MonitorSet | struct | One monitor’s write-time artifacts (compiled selector, predicate, actions), and a network’s whole compiled set plus its runtime pause set and merged source interest |
compile_monitor, build_monitor_set, spec_set_for_chain | fn | Compiles one monitor / unions a network’s interest and referenced-fields hints across its monitors / compiles every spec on a chain: all write-time, never on the hot path |
Progress, CompletionGuard | struct | The contiguous-prefix tracker that derives the checkpoint a pipeline may safely persist; see Progress and checkpointing |
PipelineCounters, CounterSnapshot | struct | Per-pipeline atomic event counts, and the point-in-time serializable copy GET /status (and this crate’s own metrics module) reads from |
EngineStatus, PipelineStatus, QueueDepth, SourceStatusView | struct / enum | The GET /status response shape: one entry per running, paused, or abandoned pipeline, its queue depths, and a serializable mirror of blockwatcher_ports::SourceStatus |
TestInput, TestOutcome, TestReport, MAX_TEST_INPUTS | enum / struct / const | The monitor dry-run’s request, per-input result, and whole-report shapes, and the shared input cap both this crate and the API refuse past |
SkipReport, SkipTarget | struct / enum | What skip_network moved a paused network’s checkpoint/start_block to, and where it was told to move it (a confirmed tip, or an absolute block) |
NO_REPLAY_PAYLOAD, NO_REPLAY_RETRACTED | const | The exact refusal messages for replaying a dead letter recorded before payload storage existed, and for a letter whose payload is a retraction; matched verbatim by the API layer |
Pipeline workers
pipeline/ holds one file per pipeline stage plus the glue between them:
nothing in the directory is shared infrastructure unrelated to running one
network’s pipeline:
File-by-file map: pipeline/
| File | Owns |
|---|---|
mod.rs | spawn itself, PipelineSpec (what a caller hands in), PipelineHandle (what a caller gets back), ChannelGauge<T> (a status-readable, non-owning view onto a bounded channel), and SourceExit (the restart supervisor’s own signal). This is the assembly file: every channel, every cancellation token, and every task the other files’ types run inside is created here, once, in spawn (pipeline/mod.rs). |
processor.rs | Processor: the decode-and-match stage. One task per network, consuming raw events and, for every active, unpaused monitor, running the decoder against that monitor’s compiled selector and the matcher against its compiled predicate (processor.rs). Gate arithmetic is not inlined here: pipeline/gate.rs applies the decision, persists the journal, and mints from indices. |
gate.rs | Apply a compiled gate’s on_hit decision, persist gate_hits/gate_meta, mint Match/Digest from returned indices. Processor calls it; window math lives in blockwatcher-gates. |
sink_worker.rs | SinkWorker and WorkItem: the delivery stage. One task per sink a network’s monitors reference; the retry attempts, the backoff between them, and the eventual dead letter all live here, in one place, so a webhook sink and a script sink fail exactly the same way regardless of what either one’s own module code does (sink_worker.rs). |
aggregate.rs | AggregateBuffer: the per-sink aggregation window. It owns what is held and until when; delivering, journaling, and dead-lettering what it hands back stays the sink worker’s job, identically for a digest and for a lone match (aggregate.rs). |
throttle.rs | ThrottleWindow: the per-sink admission window. It owns how much of the budget is left and until when; which events answer to the budget, and what becomes of one it refuses, stays the sink worker’s job, which dead-letters a refusal on the same terms it dead-letters a failed delivery (throttle.rs). |
delivery.rs | deliver_with_retry and DeliverOutcome: the retry loop itself, factored out rather than duplicated at each call site: SinkWorker::run calls it for live traffic, and ControlHandle::replay_dead_letter (control/dead_letters.rs) calls the identical function again for an operator-triggered replay. Neither caller gets its own copy of the retry-count/backoff-doubling arithmetic, or its own idea of which ErrorClass values are worth a second attempt (delivery.rs). |
checkpoint_writer.rs | CheckpointWriter and DrainSignal: the persistence stage. One task per network, persisting whatever Progress publishes and refusing anything older than what this instance already wrote (checkpoint_writer.rs). See Progress and checkpointing for what it actually does. |
progress.rs sits one directory up from all of them, deliberately: Progress
is not a task, it is a shared, lock-protected structure the processor
registers events into and sink workers complete guards against. See
Progress and checkpointing.
Pipeline worker topology
spawn (pipeline/mod.rs) builds every channel, watch, and
cancellation token a pipeline needs before it spawns a single task, and every
task below is handed only the subset it actually touches:
flowchart TD
handle["PipelineHandle<br/>(held by Engine)"]
subgraph one["one spawn() call"]
src["Source task<br/>ctx.events (Sender)<br/>ctx.interest / ctx.status (watch)<br/>ctx.cancel"]
proc["Processor task<br/>reads events_rx<br/>reads set_rx (watch)<br/>writes Progress.begin<br/>writes sink senders"]
sw["Sink worker task<br/>(one per sink)<br/>reads its own WorkItem receiver<br/>completes guards via Progress<br/>cancel = hard_cancel"]
cpw["Checkpoint writer task<br/>reads checkpoint_rx (watch)<br/>reads drain_token<br/>cancel = hard_cancel"]
prog[("Progress<br/>shared struct, not a task")]
end
src -->|"events_tx: mpsc(event_channel_capacity)<br/>pipeline/mod.rs"| proc
proc -->|"sink_tx: mpsc(sink_channel_capacity)<br/>one per sink, pipeline/mod.rs"| sw
proc -->|"begin(cursor, outstanding)"| prog
sw -->|"guard.complete()"| prog
prog -->|"checkpoint_rx: watch<Option<Checkpoint>>"| cpw
cpw -->|"put_checkpoint"| store[("checkpoint<br/>blockwatcher-storage")]
handle -->|"set_tx.send_replace<br/>(hot swap)"| proc
handle -->|"interest_tx.send_replace<br/>(hot swap)"| src
src -->|"status_tx.send_replace"| handle
parent(("parent_cancel<br/>engine root")) -.->|".child_token()"| src
hard(("hard_cancel<br/>independent root,<br/>only a deadline<br/>escalation fires it")) -.-> sw
hard -.-> cpw
proc -.->|"Arc<DrainSignal> canary<br/>dropped on task exit"| drain{{"drain_token"}}
sw -.->|"canary dropped<br/>on every worker's exit"| drain
drain -.->|"cancelled() once<br/>every canary has dropped"| cpw
classDef token fill:none,stroke:#e39a3a
class parent,hard,drain token
Two details that are easy to miss reading the concept-level diagram alone:
- The bounded
mpscchannels are the only queues in this diagram. Every other arrow is atokio::sync::watch(publishes only the latest value, never queues several) or a direct call againstProgress’s own mutex-guarded state. See Backpressure below. hard_cancelis not a descendant ofparent_cancel, and the source task never holds it at all. Only the sink workers and the checkpoint writer check it; only a deadline escalation insidedrain_pipeline(engine/drain.rs, see Boot, restart, and shutdown) ever fires it. If the pipeline’s own ordinary shutdown signal (cancel) triggered it directly instead, a sink worker or the checkpoint writer would give up on work still sitting in a bounded channel that an un-escalated drain, given the time it is normally owed, would have gone on to flush, perspawn’s own comment (pipeline/mod.rs).- The drain-signal canary is held by the processor and every sink worker,
never by the source.
DrainSignal’sDropimpl firesdrain_tokenonce every clone of it has dropped (checkpoint_writer.rs); the checkpoint writer’s ownProgressclone, andPipelineHandle’s, would otherwise keepProgress’s watch channel open forever, since neither ever drops for the life of the handle. Excluding the source is deliberate too: a source exiting has nothing to do with whether the processor and sink workers still have buffered work to finish draining.
Backpressure: the bounded channels
Between source and processor sits one tokio::sync::mpsc channel of
capacity event_channel_capacity (default 256); between processor and
each sink worker sits one of capacity sink_channel_capacity (default
64), both configured once in EngineConfig (config.rs) and
created inside spawn (pipeline/mod.rs). These are
the only bounded channels a pipeline has. Nothing else in the topology
above can exert backpressure: Progress’s internal VecDeque<Slot>
(progress.rs) has no bound at all (it grows with however many
events are in flight and shrinks as their guards complete), and every
watch channel (set_tx/set_rx, interest_tx/interest_rx,
status_tx/status_rx, checkpoint_rx) holds at most one pending value by
construction: a send_replace overwrites whatever was there, it never
queues a second one behind it. A hot-swapped monitor set or a republished
checkpoint can never itself pile up; only the mpsc channels above can.
Both channel operations are ordinary tokio::sync::mpsc::Sender::send calls
with nothing wrapped around them: no try_send, no timeout, no fallback
path (the sink dispatch call site is processor.rs). That is the
entire mechanism: a full channel just makes that one .await pend. The
pipeline § Backpressure: two bounded channels, no
drops
walks through where that pending call propagates to (the processor’s whole
dispatch loop, then the source’s own send) and Delivery
guarantees covers why a stall is the accepted
outcome rather than a drop. This page’s own contribution is the inventory
above: the bounded points it names are the only ones in the entire topology,
and their configured capacities are the only backpressure knobs
EngineConfig exposes: nothing else in spawn’s wiring can ever be the
thing that is full.
Progress and checkpointing
Delivery guarantees already names Progress
as the mechanism deciding when a cursor becomes safe to persist; this
section is the data structure and algorithm underneath that decision.
Progress’s state (State, progress.rs, held inside Inner
alongside the watch Sender, progress.rs) is a VecDeque<Slot>
behind one Mutex: each Slot (progress.rs) carries one raw
event’s cursor, its source_state,
and a remaining count of matches dispatched from it that have not yet
completed. begin (progress.rs) pushes one Slot per raw event,
in the order the processor calls it, and nothing ever removes a slot except
from the front. advance_locked (progress.rs) is the entire
algorithm on top of that queue: walk forward from the front for as long as
Slot::remaining is zero, drop every slot that check passes, and, only if
at least one slot was dropped, call send_replace with the cursor of the
last one. Running the walk and the send_replace inside the same critical
section is what rules out two concurrent completions racing each other into
the watch channel in the wrong order; a version that unlocked between the
two could have one thread publish cursor 3 and a second thread, scheduled
just behind it, publish cursor 1 afterward, and nothing would ever notice or
correct it since the slots behind both are already gone.
CompletionGuard::complete (progress.rs) is Slot::remaining -= 1
followed by the same advance_locked call; dropping
a guard without calling complete (a worker task dying mid-delivery, most
notably) leaves that slot’s remaining nonzero forever, which permanently
stalls the prefix at that slot rather than expiring or timing out. Delivery
guarantees § The contract,
precisely covers what
that guarantee means to a consumer; the mechanism above is what it compiles
down to.
CheckpointWriter (checkpoint_writer.rs) is the other half: a task
per network that watches Progress’s publish channel and persists whatever
it sees, forever retrying a failed write against the same value. Persisting
whatever the watch currently holds, rather than a queued history of values,
is safe specifically because of how advance_locked publishes: since every
publish happens while holding the same lock a later publish must also
acquire, a later value can never be an earlier cursor than one already sent.
One rule this task adds on top of
Progress’s own monotonicity: persist (checkpoint_writer.rs)
tracks last_persisted within this writer instance’s own lifetime and
refuses (counts, logs, and skips) any checkpoint older than that,
independent of what Progress would otherwise publish. That guard exists
for a source that violates its own non-decreasing-cursor contract mid-run;
it is explicitly not enforced across a restart, because a fresh writer’s
first persist is unconditional: the mechanism Boot, restart, and
shutdown below relies on to make a legitimate
rewind (a positively detected reorg, a resumed process) possible at all.
stateDiagram-v2
[*] --> Waiting
Waiting --> Waiting: sample tick (every 1s)<br/>gauge IN_FLIGHT_EVENTS
Waiting --> CheckRegression: checkpoint_rx.changed()<br/>or drain fires<br/>(borrow_and_update reads the latest value)
CheckRegression --> Waiting: cursor behind last_persisted<br/>refused, counted<br/>(CHECKPOINT_REGRESSIONS_REFUSED)
CheckRegression --> Persisting: cursor at or past last_persisted
Persisting --> Persisted: storage.put_checkpoint Ok<br/>last_persisted = cursor
Persisted --> Waiting: loop, unless drain already fired
Persisted --> [*]: drain fired, nothing left to flush
Persisting --> Backoff: storage.put_checkpoint Err<br/>counted (CHECKPOINT_WRITE_FAILED)
Backoff --> Persisting: backoff elapses<br/>(doubles, capped at max_backoff_ms)
Backoff --> Abandoned: hard_cancel fires<br/>(deadline escalation only)
Abandoned --> [*]
Waiting --> [*]: drain fired,<br/>nothing pending to flush
Two states are worth naming precisely against the source: CheckRegression
is persist’s own comparison against last_persisted
(checkpoint_writer.rs), and it is the only place a value is
discarded rather than eventually written: a discard here is never silent,
it increments PipelineCounters::checkpoint_regressions_refused and the
matching metrics.rs constant, and logs at error level naming both
cursors. Abandoned is reachable only from Backoff, and only because
hard_cancel fired: the writer never gives up on a value it has already
decided to persist for any other reason, including the pipeline’s own
ordinary cancel token, which this task does not even hold a reference to.
The control plane
blockwatcher-core’s own module comments draw the same distinction this section
does: control/ is “the caller-facing half — it owns the validate/compile/
persist ordering, the optimistic concurrency, and the mutation lock”
(engine/control.rs:4-6), while engine/control.rs (a different file,
one level up from control/) is “the engine-side half,” owning what
happens to the running pipeline once a mutation has already validated and
persisted its change (engine/control.rs:1-2).
control/: the caller-facing half
One file per method family on ControlHandle:
File-by-file map: control/
| File | Owns |
|---|---|
mod.rs | ControlHandle itself; pause_monitor/resume_monitor and pause_network/resume_network: the persisted lifecycle toggles, see below; status(); and network_context, the shared resolve-network-then-decoder-then-specs helper every method that acts on one monitor uses (control/mod.rs). |
writes.rs | put_monitor, put_network, put_sink, put_spec, and restart_affected: creation and update for every resource kind, each validating references and compiling before persisting, then restarting or hot-swapping whatever running state the write affects (control/writes.rs). |
deletes.rs | delete_monitor, delete_network_checkpoint, delete_network, delete_sink, delete_spec, and refuse_if_referenced: removal, and the one rule this family owns on its own: a resource any stored monitor still names is refused before storage is touched at all (control/deletes.rs). |
inspect.rs | test_monitor (the dry run: TestInput/TestOutcome/TestReport, MAX_TEST_INPUTS) and spec_schema: the read-only surface, taking no mutation lock and touching nothing running (control/inspect.rs). |
skip.rs | skip_network, SkipTarget, SkipReport: the operator “skip catch-up” patch: moves a paused network’s checkpoint and source start_block forward without replaying the gap, deliberately bypassing put_network (which would clear the pause and restart) (control/skip.rs). |
dead_letters.rs | list_dead_letters, replay_dead_letter, find_dead_letter, and NO_REPLAY_PAYLOAD: a storage read and an operator-triggered re-entry into deliver_with_retry (see Pipeline workers above) (control/dead_letters.rs). |
Every mutating method here follows the same order, stated once in
control/mod.rs’s own module doc comment and never varied: “validate
references, compile or construct whatever needs it, persist with optimistic
concurrency, then update whatever is actually running — in that order,
always, for every mutating method, writes and deletes alike”
(control/mod.rs:65-69). A write or delete that has already persisted is
treated as done regardless of what happens next: whatever running state it
still has to touch (a republished monitor set, a restarted pipeline) is
attempted best-effort, and a failure there is logged with the resource and
reason rather than turned into an error response for a resource storage
already durably holds.
Pause is persisted
Both pause surfaces, ControlHandle::pause_monitor/resume_monitor and
pause_network/resume_network (control/mod.rs), persist the pause to
storage before touching anything running, and every path that builds a
monitor set or boots a pipeline reads that persisted state back:
- Pausing a monitor writes its id to storage’s paused-monitor set
(
ControlHandle::set_paused,control/mod.rs), then republishes the network’s running monitor set throughbuild_monitor_setwith that same persisted set (Engine::republish_paused_monitors,engine/control.rs). The persist is the request: a pipeline that is not running, or a republish that fails, is logged and left for the next rebuild, which reads the persisted set from scratch and always applies it — the same path a network, sink, or spec write’s restart takes, and the same one boot itself seeds from (engine/boot.rs). Deleting a monitor clears its pause row with it (control/deletes.rs). - Pausing a network persists the pause (
Storage::set_paused), marks it inEngine.paused_networks(aHashSet<NetworkId>,engine.rs) so a concurrentSourceExitcannot race a restart back into existence, then stops its pipeline through the same bounded drain a restart uses. Resuming clears the persisted row and restarts the pipeline unless one is already running for it; a restart that then fails is logged rather than returned, since the persist already succeeded (resume_network,control/mod.rs). Boot reads the persisted set before constructing any pipeline and never spawns one for a paused network at all, intersected against the networks boot actually built so a pause row that outlived its network cannot render a status entry for one that no longer exists (engine.rs’s constructor). The restart supervisor consults the same in-memory mark (is_paused) and the delete tombstone (is_tombstoned) before ever scheduling a restart of its own, so an operator’s pause is never silently undone by a source exiting and the supervisor bringing it back (engine/supervisor.rs). The tombstone is aHashMapof network id to the sink ids the last pipeline for that network was spawned with, so a lateInvalidatedafter delete still retracts only those sinks. Deleting a network clears its pause row with it.
A monitor’s pause additionally drops out of the source’s merged interest
hint the moment it is paused: build_monitor_set excludes a paused
monitor’s selector from the interest union it hands the decoder
(compile.rs), so pausing a monitor can only ever shrink what its
network’s source is asked to fetch, never leave it fetching data nothing
will read.
Boot, restart, and shutdown
engine.rs plus its engine/ submodules are the other side of the
control plane: what an engine does on its own behalf (boot, shutdown,
restart supervision) rather than what a caller’s mutation asks of it.
File-by-file map: engine/
| File | Owns |
|---|---|
boot.rs | validate_and_build: the single all-or-nothing pass both Engine::start and Engine::validate run through: parse and type-check every stored resource, build the matcher and one decoder per registered decoder module, construct every sink, check that no monitor names a network, sink, or spec that either doesn’t exist or doesn’t match, and then, network by network, compile its monitors and construct its source and read its checkpoint. A failure on network N never leaves networks 1..N-1 half-validated behind it: nothing is handed to a caller to spawn until the whole pass clears (engine/boot.rs). Also check_checkpoint_provenance, re-exported for control/writes.rs to share (engine/boot.rs). |
control.rs | build_pipeline_spec, restart_pipeline, stop_pipeline, hot_swap_monitors, republish_paused_monitors: everything that happens to a running pipeline once a caller-facing mutation has already validated and persisted its change. Every function here requires Engine’s mutation lock already held by its caller; none of them takes it (engine/control.rs). stop_pipeline also owns the StopOutcome a caller reads to know whether storage is safe to touch next: after an escalated drain it awaits Storage::quiesce under drain::QUIESCE_BUDGET, and restart_pipeline refuses to spawn a replacement (leaving the network abandoned) when that wait times out, rather than read a checkpoint a residual write from the abort could still race. |
drain.rs | drain_pipeline: the shared teardown ladder both Engine::shutdown and a single-network stop_pipeline/restart_pipeline drain through: await each task to a deadline, then hard_cancel, a brief grace window, then a forced abort() for any straggler, counted under PIPELINES_ABORTED the moment any escalation was needed at all (engine/drain.rs). Also QUIESCE_BUDGET, the bound both Engine::shutdown and stop_pipeline apply to Storage::quiesce right after an escalation, so an aborted task’s detached storage call is waited out (or, on shutdown, at least warned about and counted as PIPELINES_QUIESCE_TIMEOUT) before its caller treats the network as clean. |
supervisor.rs | The restart supervisor task Engine::start spawns. Reacts to nothing but the SourceExit channel: Invalidated is a control path (drain, retract, rewind, restart via engine/invalidate.rs), not a crash backoff. A failed invalidate is counted as SOURCE_INVALIDATION_FAILURES, not SOURCE_RESTART_FAILURES. Every other exit schedules a restart_pipeline call at a per-network delay that doubles with each consecutive failure and resets once a network has run healthily past a configurable interval, and it drops an exit for a network that is already paused or tombstoned on the spot rather than counting it as anything (engine/supervisor.rs). |
invalidate.rs | handle_invalidation: cooperative drain, prune gate_hits where cursor > from (do not delete cursor ≤ from), re-read the checkpoint, journal list_deliveries_after, one-shot retract pass through deliver_with_retry, then rewind only when from is strictly behind the stored checkpoint (a rewind that would advance it, or fabricate one where none exists, is refused, counted under REWINDS_REFUSED, and the checkpoint stays put), and restart. Retracts go to the live pipeline’s sinks, or after delete to the sink ids stored on that network’s runtime tombstone (not every remaining sink resource). An unrecovered retract leaves the checkpoint unrewound (engine/invalidate.rs). |
Two things a reader coming from pipeline/mod.rs’s spawn should carry
into this section: restart_pipeline’s replacement pipeline is built to
completion before the existing one is touched, so a fallible step (a
storage error, a module that no longer constructs) always leaves whatever
was running before completely untouched; and the replacement’s checkpoint is
deliberately read after the old pipeline has drained, never before. A
pre-drain read would miss whatever further completions land while the old
pipeline winds down, so the replacement would come up already behind its own
predecessor and, on its first persist, push the stored cursor backward
(engine/control.rs, doc comment). This is the one place in the
whole crate a restart is allowed to make a checkpoint “rewind” in
CheckpointWriter’s sense above: the fresh writer’s first persist is
unconditional, precisely so this legitimate case is not caught by the same
guard that refuses a source’s own contract violation.
Neighbours
blockwatcher-core depends on, in production (the same list named in
full above, and no chain SDK, HTTP client, or storage driver anywhere in
this list or its transitive tree (see the exemption-free guarantee
described above):
blockwatcher-typesblockwatcher-portsserdeserde_jsonthiserrortokio(macros/rt/timefeatures)tokio-utilfuturesmetricstracing
and, in [dev-dependencies] only:
blockwatcher-ports(fakesfeature):FakeSource,FakeDecoder,FakeMatcher,FakeSink,MemoryStorage,FlakyStorage; every test in this crate runs against these, never a real moduleblockwatcher-testkit:RecordingMetrics, used to assert on what a pipeline flow emits through themetricsfacadeasync-trait: implementingSource/Sinktest doubles (PanickingSource,CancelThenOkSource,SlowSink) thatblockwatcher-ports::fakesdoesn’t already providetokio(rt-multi-thread/test-utilfeatures): multi-threaded and paused-time async tests
The following crates depend on it directly (per the dependency table):
blockwatcher-api: wraps everyControlHandlemethod in an HTTP routeblockwatcher-embed: callsEngine::startfor in-process hosts and for the binary’s own bootblockwatcher(binary): owns the process’s own shutdown signal, and callsEngine::validatefor its offline config-check mode
Reading the source
- Start at
lib.rs: the module list and the full re-export surface; every name in the key types table above ispubre-exported here (CheckpointWriteris the one type this page covers in depth that is not:mod pipeline;has nopub, so nothing outsideblockwatcher-corecan name it,pub structon the type itself notwithstanding). error.rs:EngineError, every variant naming the identity of what it happened to; read this before anything else, since almost every fallible function in the crate returns it.config.rs:EngineConfigandSourceRestart, and their defaults (event_channel_capacity: 256,sink_channel_capacity: 64,drain_deadline_ms: 10_000,journal_depth: 1024).catalog.rs:ModuleCatalog, and thefamily!macro that generates one register/lookup pair per port family so registering, say, a sink factory as a source is a compile error.compiled.rs, thencompile.rs:CompiledMonitor/MonitorSet, and the three write-time functions that build them:compile_monitor(selectors, predicate, and gate),build_monitor_set(read its doc comment on field-union poisoning closely: one un-introspectable monitor’s predicate makes the whole network’s referenced-fields hint unknown, on purpose), andspec_set_for_chain.resources.rs:Resources/Versioned<T>, and why a listing (load) fails whole on one bad record while a by-id read (load_one) fails only the lookup that names it.counters.rs, thenmetrics.rs:PipelineCounters/CounterSnapshot, then the named Prometheus constants and the emission helpers (count,count_sink,count_gate,gauge) every pipeline stage calls alongside its matching atomic increment. Gate metric names (blockwatcher_gate_hits_totaland the rest) are the same strings Observability lists. Invalidate, retract, and journal-gap counters live here too; they have noPipelineCounterstwin. See blockwatcher-metrics § From a pipeline event to a scrape line for where the second half of that pair actually goes.progress.rs:Progress/CompletionGuard/Slot/advance_locked; read this beforepipeline/checkpoint_writer.rs, which depends on everything it guarantees.pipeline/mod.rs, thenprocessor.rs,sink_worker.rs,delivery.rs,checkpoint_writer.rs, in that order. See Pipeline workers above for what each one owns.engine.rs:Engine,EngineDeps,ShutdownReport, and themoddeclarations for its own submodules; readEngine::startandEngine::shutdownhere before descending intoengine/boot.rs,engine/control.rs,engine/drain.rs,engine/supervisor.rs, andengine/invalidate.rs. See Boot, restart, and shutdown above for what each one owns.control/mod.rs, thenwrites.rs,deletes.rs,inspect.rs,skip.rs,dead_letters.rs. See The control plane above for what each one owns, and readmod.rs’s module doc comment first: it states the validate-persist-swap ordering and the post-persist contract every one of the other files holds to.status.rs:SourceStatusView,QueueDepth,PipelineStatus,EngineStatus, and the small pure functions (head_of,lag_between,queue_depth_with_capacity)Engine::status_snapshotcomposes them from.