Skip to main content

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;