decdn_node/chain_events/resumable_watcher.rs
1//! Resumable `eth_getLogs` scan vocabulary (#1092/#1106/#1108).
2//!
3//! This module owns the cursor + sink vocabulary every on-chain watcher shares;
4//! the loop that drives it lives in
5//! [`multiplexed_poller`](super::multiplexed_poller), which reads head once per
6//! tick, scans each route's `[cursor, head]` gap in bounded windows
7//! (`blockchain.get_logs_max_block_span`) via one merged `eth_getLogs`, hands
8//! each log to the owning [`LogSink`], then advances — and, for a route whose [`CursorStart`] owns a
9//! [`Checkpoint`], durably records — the cursor per window. The **first tick's**
10//! large range *is* the historical backfill; later ticks are the live tail. The
11//! poller uses `eth_getLogs` rather than alloy's `watch_logs` (`eth_newFilter` +
12//! `eth_getFilterChanges`), which the default public Arbitrum Sepolia RPC and
13//! most keyless endpoints reject with `-32601` (#1106).
14//!
15//! Two properties matter for correctness:
16//!
17//! - **Resumability (#1108).** Persisting the cursor *per window* during the
18//! first big backfill means a rate-limited cold start that dies part-way
19//! through resumes near where it stopped instead of re-scanning from the
20//! configured floor. Combined with the per-tick backoff (which never exits the
21//! process — only the startup preflight does), a throttled boot makes forward
22//! progress across restarts rather than crash-looping.
23//! - **Idempotency.** A tick that errors mid-window leaves the cursor at the
24//! last *completed* window, so the failed window re-scans on retry; a shallow
25//! reorg on resume re-scans `reorg_margin` blocks. Every sink must therefore
26//! apply logs idempotently (dedup by id / set insertion / authoritative
27//! re-read).
28
29use std::future::Future;
30use std::time::{Duration, Instant};
31
32use alloy::rpc::types::Log;
33use anyhow::{Context, Result};
34use decdn_common::redact::sanitize_error_sources as n;
35use decdn_incentive::{CheckpointKey, KeyedCheckpointStore};
36use std::sync::Arc;
37use tokio_util::sync::CancellationToken;
38use tokio_util::task::AbortOnDropHandle;
39use tracing::warn;
40
41/// A durable per-key scan checkpoint: the store plus the key it reads and writes
42/// under. Carried by [`CursorStart::FromCheckpoint`], which persists its cursor
43/// forward and reads its resume floor back from the same key.
44pub(crate) struct Checkpoint {
45 pub(crate) store: Arc<dyn KeyedCheckpointStore>,
46 pub(crate) key: CheckpointKey,
47}
48
49/// How the **first** tick derives its scan floor, and whether the cursor is
50/// persisted forward.
51///
52/// Persistence rides inside the variants that own a [`Checkpoint`] rather than
53/// on a separate field, so two invalid shapes cannot be constructed: a watcher
54/// that resumes from a checkpoint cannot be configured without one, and a
55/// watcher that re-derives its floor from head cannot declare a margin or window
56/// it never reads.
57/// Where a [`CursorStart::FromCheckpoint`] watcher starts on a first-ever boot,
58/// when no cursor has ever been persisted.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub(crate) enum ColdStart {
61 /// Start at **head**: nothing predates this node, so there is no history
62 /// worth replaying. Settlement `ChannelOpened` — a channel opened before the
63 /// node's keypair existed cannot be one of ours.
64 Head,
65}
66
67pub(crate) enum CursorStart {
68 /// Start at an explicit block — a bootstrap snapshot block, already covered
69 /// by an out-of-band enumeration. Bypasses floor derivation entirely and
70 /// never persists: a seeded projection is an ephemeral live-follow rebuilt
71 /// from its enumeration each boot (the capacity-bond registry, the slash
72 /// store, the blacklist deny-set).
73 Seeded { at: u64 },
74 /// Resume from a durable checkpoint, rewound by `reorg_margin`, persisting
75 /// forward each window. This is the only start that reads a checkpoint to
76 /// derive its floor (settlement `ChannelOpened`).
77 ///
78 /// What a first-ever (cold-store) boot does is the caller's choice — see
79 /// [`ColdStart`]. Getting that wrong is a correctness bug, not a tuning
80 /// knob, so it is an explicit field rather than a default.
81 FromCheckpoint {
82 checkpoint: Checkpoint,
83 /// Blocks to rewind the checkpoint on resume (reorg safety), normally
84 /// [`super::REORG_MARGIN_BLOCKS`]. Only this variant resolves a floor
85 /// from a durable cursor, so it is the only reader of a margin — every
86 /// other start re-derives its floor from head each boot.
87 reorg_margin: u64,
88 /// Floor to use when no cursor has ever been written.
89 cold_start: ColdStart,
90 },
91 /// Re-derive the floor from head each boot as `head - window_blocks` (clamped
92 /// `>= from_block`); do not persist. Used where a resume cursor is unsafe or
93 /// unnecessary — a bounded recent window re-scanned every boot (the rate-bounds
94 /// watcher's `window_blocks: 0` live-from-head follow). `window_blocks` is
95 /// always a *bounded* recent window.
96 HeadMinusWindow { window_blocks: u64 },
97}
98
99impl CursorStart {
100 /// The explicit starting cursor, if this is a [`Self::Seeded`] start. `Some`
101 /// pre-sets the route's cursor so the first tick scans from here and never
102 /// resolves a floor.
103 pub(crate) const fn seed(&self) -> Option<u64> {
104 match self {
105 Self::Seeded { at, .. } => Some(*at),
106 Self::FromCheckpoint { .. } | Self::HeadMinusWindow { .. } => None,
107 }
108 }
109
110 /// The durable checkpoint this start writes forward to (and, for
111 /// [`Self::FromCheckpoint`], reads its resume floor from), if any.
112 const fn checkpoint(&self) -> Option<&Checkpoint> {
113 match self {
114 Self::FromCheckpoint { checkpoint, .. } => Some(checkpoint),
115 Self::Seeded { .. } | Self::HeadMinusWindow { .. } => None,
116 }
117 }
118
119 /// Resolve the first tick's scan floor against the current scan upper bound.
120 /// Reached only on the `None` cursor branch, so [`Self::Seeded`] — whose
121 /// cursor is pre-set from [`Self::seed`] — never lands here and resolves to
122 /// `from_block` defensively.
123 ///
124 /// A checkpoint read error is **retryable**: falling back to head would
125 /// anchor the first persisted window at head and durably *overwrite* the
126 /// stored floor, permanently discarding the downtime gap the checkpoint
127 /// exists to cover (#751/#762) — so the tick fails into backoff and
128 /// re-resolves next tick rather than degrading to a silent rescan.
129 pub(crate) fn initial_from(&self, from_block: u64, head: u64) -> Result<u64> {
130 match self {
131 Self::FromCheckpoint {
132 checkpoint,
133 reorg_margin,
134 cold_start,
135 } => {
136 let last = checkpoint
137 .store
138 .load_checkpoint(checkpoint.key)
139 .with_context(|| {
140 format!("read watcher scan checkpoint {}", checkpoint.key.as_str())
141 })?;
142 // `ColdStart::Head`: a first-ever (cold-store) boot has no history
143 // to replay, so `resolve_persisted_start`'s `None` arm anchors it
144 // at head; a warm resume rewinds the stored cursor by `reorg_margin`.
145 match cold_start {
146 ColdStart::Head => Ok(resolve_persisted_start(
147 last,
148 head,
149 from_block,
150 *reorg_margin,
151 )),
152 }
153 }
154 Self::HeadMinusWindow { window_blocks } => {
155 Ok(resolve_head_window_start(head, *window_blocks, from_block))
156 }
157 // A seeded start pre-sets the cursor (see `seed`), so the poller's
158 // floor-resolution `None` branch never calls this arm. The deploy floor is the safe
159 // fallback (anti-panic policy); the `warn!` makes a future break of
160 // the "Seeded ⇒ cursor pre-seeded" invariant observable rather than a
161 // silent full re-scan from the deploy block.
162 Self::Seeded { at, .. } => {
163 warn!(
164 invariant = "seeded_start_reached_initial_from",
165 seed = *at,
166 from_block,
167 "cursor seed invariant violated; falling back to deploy floor"
168 );
169 Ok(from_block)
170 }
171 }
172 }
173
174 /// Durably record `block` as scanned (no-op for the non-persisting
175 /// [`Self::HeadMinusWindow`] and [`Self::Seeded`] starts).
176 /// Best-effort: a lost write only widens the next rescan (see the store's
177 /// monotonic-floor contract).
178 pub(crate) fn persist(&self, block: u64) {
179 if let Some(cp) = self.checkpoint()
180 && let Err(err) = cp.store.record_checkpoint(cp.key, block)
181 {
182 warn!(error = %n(&err), key = cp.key.as_str(), block, "failed to persist watcher checkpoint");
183 }
184 }
185
186 /// Force any buffered checkpoint out on graceful shutdown.
187 pub(crate) fn flush(&self) {
188 if let Some(cp) = self.checkpoint()
189 && let Err(err) = cp.store.flush_checkpoint(cp.key)
190 {
191 warn!(error = %n(&err), key = cp.key.as_str(), "failed to flush watcher checkpoint");
192 }
193 }
194}
195
196/// Per-log consumer. Decodes the log and applies it to the watcher's in-memory
197/// projection. Implementations MUST be idempotent under re-scan (see the module
198/// doc) and MUST NOT return `Err` for an *undecodable* log — a deterministic
199/// re-scan of a permanently-undecodable log would hot-loop the cursor forever,
200/// so a decode failure is logged and skipped (`Ok`). Return `Err` only for a
201/// *retryable* failure (a durable-write error, a follow-up RPC that should be
202/// retried) so the tick backs off and re-scans the window.
203pub(crate) trait LogSink: Send {
204 /// Apply one log to the projection.
205 fn apply(&mut self, log: Log) -> impl Future<Output = Result<()>> + Send;
206
207 /// Run once at the end of a tick after every window drained cleanly — the
208 /// seam for an authoritative reconcile (e.g. origin's `getOrigins` re-read).
209 /// Default: no-op.
210 fn on_tick_complete(&mut self) -> impl Future<Output = Result<()>> + Send {
211 async { Ok(()) }
212 }
213
214 /// Run on the tick a route recovers from an errored one, before that tick's
215 /// [`Self::on_tick_complete`]. The seam for forcing an authoritative re-read
216 /// after an outage rather than waiting out a cadence: a sink whose reconcile
217 /// is cadence-gated clears its own clock here, and its next
218 /// `on_tick_complete` — the one on this same tick — re-reads.
219 ///
220 /// A cadence is not equivalent. The reconcile does not run at all while the
221 /// route is errored, and a reconcile whose own read failed defers itself a
222 /// further interval, so after a long RPC outage the first repair is whatever
223 /// cadence tick happens to land after recovery, with no relationship to when
224 /// the watcher came back.
225 ///
226 /// Sync and infallible on purpose: it exists to clear local state, and a
227 /// failure here has nowhere useful to go — the reconcile that follows on
228 /// this same tick is what does the work, and reports its own outcome.
229 ///
230 /// A sink whose `on_tick_complete` is cadence-gated wants
231 /// [`clear_cadence_on_recovery`] here. Default: no-op, for a sink whose
232 /// reconcile runs every tick and so has no clock to clear.
233 fn on_recovered(&mut self) {}
234}
235
236/// Divisor turning a sink's own reconcile cadence into the minimum age its last
237/// reconcile must have before a watcher recovery forces another.
238///
239/// A recovery edge fires whenever a route comes back from an errored tick, and
240/// an endpoint that flaps rather than staying down produces one on roughly the
241/// backoff interval. Without a floor each flap costs a full authoritative
242/// re-read aimed at an endpoint that is already failing. Fifteen keeps the
243/// forced repair an order of magnitude faster than the cadence — which is the
244/// entire point of the edge — while bounding what a flap can cost.
245const RECOVERY_FLOOR_DIVISOR: u32 = 15;
246
247/// Clear a cadence clock so the reconcile on this same tick re-reads, unless it
248/// already read within `interval / RECOVERY_FLOOR_DIVISOR`.
249///
250/// The idiom for [`LogSink::on_recovered`] in a sink whose `on_tick_complete`
251/// re-reads authoritative state on a cadence. Such a sink is at its most stale
252/// exactly when a route recovers: the reconcile is skipped entirely while the
253/// route is errored, so the cadence repair has not been running, and a reconcile
254/// whose own read then failed stamped the clock anyway and deferred a further
255/// interval.
256pub(crate) fn clear_cadence_on_recovery<C: CadenceClock>(
257 clock: &mut Option<C>,
258 interval: Duration,
259) {
260 let floor = interval / RECOVERY_FLOOR_DIVISOR;
261 if clock.is_none_or(|last| last.elapsed() >= floor) {
262 *clock = None;
263 }
264}
265
266/// The instant types a sink stamps its cadence clock with. Sinks differ —
267/// `blacklist_watcher` runs on `tokio::time` so its tests can pause the clock —
268/// and [`clear_cadence_on_recovery`] only ever asks one question of either.
269pub(crate) trait CadenceClock: Copy {
270 /// Time since this instant was taken.
271 fn elapsed(self) -> Duration;
272}
273
274impl CadenceClock for Instant {
275 fn elapsed(self) -> Duration {
276 Instant::elapsed(&self)
277 }
278}
279
280impl CadenceClock for tokio::time::Instant {
281 fn elapsed(self) -> Duration {
282 tokio::time::Instant::elapsed(&self)
283 }
284}
285
286/// A loop-level observability hook carried on a
287/// [`Route`](super::multiplexed_poller::Route). Boxed so a watcher can close over
288/// its `Arc<Metrics>` without the generic poller knowing the concrete metric.
289///
290/// Hooks SHOULD be panic-free: in practice every hook is a `metric_hook` closure
291/// that only bumps an infallible counter/gauge. `on_task_panic` additionally runs
292/// from a `Drop` guard during an unwind — where a second panic would abort the
293/// process — so the poller's guard wraps that one call in `catch_unwind` as a
294/// backstop; the other hooks fire on normal paths and are not guarded.
295pub(crate) type WatcherHook = Box<dyn Fn() + Send + Sync>;
296
297pub(crate) fn fire(hook: Option<&WatcherHook>) {
298 if let Some(hook) = hook {
299 hook();
300 }
301}
302
303/// Resolve a [`CursorStart::FromCheckpoint`] watcher's initial scan floor from
304/// its stored checkpoint and the current scan upper bound. `Some(b)` →
305/// `b - margin` (reorg rewind), floored at `from_block` (the contract does not
306/// exist below its deploy block) and clamped `<= head` (a checkpoint momentarily
307/// ahead of a lagging RPC head never inverts the range). `None` (first-ever,
308/// cold-store boot) → `head`: nothing predates this node, so there is no history
309/// to replay. Pure so the start is unit-testable without a live provider.
310/// Generalizes the settlement watcher's original `resolve_backfill_start` (which
311/// was `None => head`).
312pub(crate) const fn resolve_persisted_start(
313 last: Option<u64>,
314 head: u64,
315 from_block: u64,
316 margin: u64,
317) -> u64 {
318 match last {
319 Some(b) => {
320 let rewound = b.saturating_sub(margin);
321 let floored = if rewound > from_block {
322 rewound
323 } else {
324 from_block
325 };
326 if floored < head { floored } else { head }
327 }
328 None => head,
329 }
330}
331
332/// Resolve a [`CursorStart::HeadMinusWindow`] watcher's floor: `head - window`,
333/// clamped `>= floor` and `<= head`. `window == 0` → `head` (live-from-head, no
334/// backfill). Pure and unit-testable.
335pub(crate) const fn resolve_head_window_start(head: u64, window: u64, floor: u64) -> u64 {
336 let start = head.saturating_sub(window);
337 let floored = if start > floor { start } else { floor };
338 if floored < head { floored } else { head }
339}
340
341/// Owns a running watcher task and the shutdown token that stops it. The token
342/// is minted inside [`super::multiplexed_poller::spawn`] and never escapes except
343/// through [`shutdown`], so no call site can conjure or drop an inert token — the
344/// mistake #1230 fixed by convention, made unrepresentable (#1236). [`shutdown`]
345/// is the graceful path (the loop flushes every route's cursor and returns); the
346/// wrapped [`AbortOnDropHandle`] guard is the hard safety net when the handle drops
347/// without a prior `shutdown`.
348///
349/// [`shutdown`]: WatcherHandle::shutdown
350///
351/// Public because [`multiplexed_poller::spawn`](super::multiplexed_poller::spawn)
352/// returns it and external integration tests drive that spawn.
353#[derive(Debug)]
354pub struct WatcherHandle {
355 shutdown: CancellationToken,
356 _task: AbortOnDropHandle<()>,
357}
358
359impl WatcherHandle {
360 /// Wrap an already-minted shutdown token and its owning task. The
361 /// [`multiplexed_poller`](super::multiplexed_poller) spawn mints the token
362 /// and constructs this handle, so the token is always paired with a live
363 /// task and can never be inert.
364 pub(crate) const fn new(shutdown: CancellationToken, task: AbortOnDropHandle<()>) -> Self {
365 Self {
366 shutdown,
367 _task: task,
368 }
369 }
370
371 /// Signal the loop to flush every route's cursor and return. Idempotent; the
372 /// runtime calls it at the ordered stop point in graceful shutdown.
373 pub fn shutdown(&self) {
374 self.shutdown.cancel();
375 }
376}
377
378#[cfg(test)]
379mod tests;