ADR-027: The block-pump dispatch seam — one owner, three application-specific pipes¶
Status: accepted. Codifies the block pump’s delivery architecture (ergo epic 5CGU4V,
tasks B1–B4): the pump’s hand-offs to the sink, the solver-state verifier, and Python’s
block clock are owned by a single dispatch owner but delivered over three
application-specific pipes, each with the delivery semantics its task needs. A future
architecture review must not re-suggest a third bespoke channel or a single-bus unification.
Superseded in part (epic
MROOY7, ADR-041,SZJUKL): the one-owner dispatch layer (theDispatchOwner), theDrainWorkFIFO, and the coordinator-owned pipes were retired; stage work runs inline in the pump driver and the surviving channels are sinks at the Published edge (the block-clock pipe and the result batch). Historical record below.
Context¶
BlockPump::run_with_stream (rust/crates/engine/degenbot-bot/src/bot_core/block_pump.rs) drives the
block pump’s drain loop. Its hand-offs were ad-hoc and coupled:
The drain pipe sent
Drain/Finalize/Publish/Notifythrough onempscto a background drainer task — but mixed delivery semantics, FIFO-queuingnewHeadsnotifications behind heavy Möbius solves, and shipped under an opt-in flag (DEGENBOT_DECOUPLE_DRAIN) with an inline fallback that had to stay behavior-identical.The verifier (ADR-021 solver-state accuracy gate) used a latest-wins
watch.The block clock (Python’s head tracker) rode the drain FIFO via
DrainWork::Notify, so a slow solve delayed Python’snewHeadstick by the solve duration.Liveness was a 30s time-based poll watchdog (
DRAINER_STALL_SECS) — a tuned knob that silently buffered a stalled drainer for up to 30s before failing.
This was hard to reason about (the long pole: the WS poller parking behind GIL-bound Python / Möbius solve), and the both-modes (inline vs decoupled) divergence had to be re-proven correct twice.
Decision¶
One dispatch owner module (rust/crates/engine/degenbot-bot/src/bot_core/event_dispatch.rs,
DispatchOwner) owns all three pipes and coordinates ordering/liveness in one place. It is
a coordinated home — NOT a single bus; each pipe keeps the delivery semantics its task
needs (“one owner, multiple application-specific pipes”).
Drain pipe — an ordered FIFO (
mpsc) carryingDrain/Finalize/Publishto a background drainer task →DrainSink. Solves/dispatch/finalize run in enqueue order; FIFO + the engine/sink locks give the deferred work the semantics the pre-B4GX7C inline path had. The background drainer is the sole mode — the inline path and theDEGENBOT_DECOUPLE_DRAINflag are retired (bot_env_flag_default_offdeleted).Block-clock pipe — a direct
DispatchOwner::notify_block→sink.notify_block, deliberately NOT aDrainWorkitem, so anewHeadstick never rides the drain FIFO and is delivered ASAP, 1:1 (no coalescing).SolveCoordinator::notify_blockno longer takes thedrain_lock(theenginesvec is frozen after start — ADR-006 late-registration panics — so the read-only fan-out needs no lock), so the clock does not contend with the drain fan-out. Callers hold no ordering guarantee on solver results.Verifier pipe — a latest-wins
watchto the solver-state verifier task. Only the most recent published block is ever verified (ADR-021); non-blocking so a slow verify can never stall the pump.Stall backstop — the drain-pipe liveness check (B3), soak-hardened: the pump aborts when the queue holds a backlog (
depth >= BACKLOG_FLOOR = 2) AND the drainer has completed no work forSTALL_WINDOW(~30s). The wall-clock window (NOT event-counting) is what correctly distinguishes a frozen drainer from one mid-way through a single exceptionally long solve — a live mainnet dry-run proved that pure strike-counting (whether on pick-up depth or on completion count) false-positives under heavy multi-path solve load, aborting a busy-but-alive drainer. A drainer that progresses but falls behind is observed viaDispatchOwner::pending()(a lag metric), never aborted; a dead (closed-channel) drainer still aborts immediately on a send into a closed channel. The old 30s poll-watchdog (DRAINER_STALL_SECS) is folded into this dispatch-timeSTALL_WINDOWbackstop (checked on the pump’s dispatch, not a background poll task).
Consequences¶
The WS poller never parks behind GIL-bound Python or a Möbius solve (the drain work is deferred), and Python’s block clock is never queued behind solver work.
The both-modes divergence is gone: one delivery path in the hot loop; the flag machinery is deleted.
Liveness is robust: a frozen drainer fails loud (“fail loud, never half-alive”) within the stall window; a slow-but-alive drainer is observed via the
pending()lag metric instead of being killed.DispatchOwnerexposes a small interface (dispatch/notify/pending), giving the seam a testable surface; the pureNoProgressaccounting is unit-tested, and the frozen-drainer abort is validated by a subprocess test (no_progress_frozen_drainer_aborts_proc).
Not decided here¶
A literal latest-wins
watch/coalescing block-clock: the direct path (every header, 1:1) is strictly more complete for a head tracker and was chosen instead.A per-engine-lock bypass for
notify_block:engine.notify_blockstill briefly takes the engineMutex(a solve holds it for its full duration — documented intentional lock discipline). Thedrain_locklayer of contention is removed (B2); the residual short engine-lock acquisition is existing behavior, out of scope.
Tests¶
event_dispatchunit + subprocess tests cover the stall predicate, the not-on-the-FIFO block clock, the verifier hand-off, and the frozen-drainer abort.A 300s live mainnet dry-run soak (B6) processed ~90 blocks under heavy solve load with zero false-positive aborts and a fresh (non-solve-gated) block clock.
All 450 degenbot-bot lib tests pass (B1–B4 + the soak-driven stall fix), clippy-clean on the new/changed files, and the standalone-consumer gate is green.