Skip to main content

decdn_node/chain_events/
multiplexed_poller.rs

1//! Multiplexed `eth_getLogs` polling: one poll tick, one `get_logs` call per
2//! backfill window, demuxed by `(address, topic0)` into per-route sinks.
3//!
4//! [`resumable_watcher`](super::resumable_watcher) gives every watcher its own
5//! cursor loop and its own `eth_getLogs` call each tick. That is right when a
6//! watcher's filter genuinely differs from its siblings', but five watchers on
7//! this node scan disjoint `(address, topic0)` slices of the same few
8//! contracts — so five independent loops cost five `eth_getLogs` calls per tick
9//! where one merged call, demuxed after the fact, would do. [`MultiplexedPoller`]
10//! is that merge: one filter, one `eth_getLogs` per window, fanned out by
11//! `(address, topic0)` into each watcher's own [`ErasedSink`].
12//!
13//! Three properties carry over from `resumable_watcher` unchanged:
14//!
15//! - **Resumability and idempotency** (see that module's doc) — the cursor
16//!   machinery ([`CursorStart::seed`]/[`CursorStart::initial_from`]/
17//!   [`CursorStart::persist`]/[`CursorStart::flush`]) is reused verbatim, one
18//!   instance per route.
19//! - **Per-route cursors, not one shared cursor.** Merging the RPC call does
20//!   not merge the cursors: each route floor derives, advances, and persists
21//!   independently, scanned as `[min(route floors), head]` in one call. This is
22//!   what preserves settlement's deep `FromCheckpoint` backfill without
23//!   dragging every sibling route through the same history, and what makes
24//!   per-route error isolation possible below.
25//! - **Per-route error isolation.** A route whose sink errors is marked
26//!   `errored` for the rest of the tick: it holds its cursor (re-scanning the
27//!   same range next tick) while sibling routes keep advancing and persisting.
28//!   The tick as a whole still returns `Err` when any route errored, so the
29//!   *loop* backs off — a route that is failing should not poll as fast as one
30//!   that is not — but that backoff never discards a healthy sibling's
31//!   progress.
32//!
33//! # Hooks fire per route, not per loop
34//!
35//! A single-sink loop would fire `on_backoff`/`on_established` once per tick,
36//! because there is exactly one sink. Here the *tick* fires each route's hooks
37//! independently (routes fail independently), and the loop's `Err` arm only
38//! sleeps the backoff — it never fires a hook itself. The exception is a
39//! failure in the shared window scan: the head read, a route's checkpoint-load
40//! floor derivation, or a merged `get_logs` call that is not retried, all
41//! before any route-specific step runs; or a stall past
42//! [`GET_LOGS_STALL_BUDGET`], after the tick's completed windows are applied
43//! and persisted but before the reconcile. Those fail the *whole* tick, so
44//! [`fail_whole_tick`] fires `on_backoff` for every route directly at the
45//! failure site — every route is equally down, as five independent watchers
46//! that each read the shared head through
47//! [`super::shared_head::SharedHead`] would all convoy into backoff together.
48//!
49//! The `on_established`/`on_backoff` *edges* are suppressed once `shutdown` is
50//! cancelled (a tick that only "succeeded" because a sink observed the cancel
51//! token must not flip a readiness gate open), while `on_tick_success` keeps
52//! stamping unconditionally.
53//!
54//! # Provider range caps
55//!
56//! A `get_logs` failure that [`is_range_rejection`] recognises does not fail the
57//! tick. [`run_tick`] sets the window span to half the rejected window's length
58//! and retries the same start block at once; no route hook fires. The span
59//! doubles back after a run of accepted windows ([`WindowSpan`]), so a
60//! transient rejection does not cost throughput until restart. A rejected one-block
61//! window, and any other `get_logs` failure that is not retried below, goes
62//! through [`fail_whole_tick`].
63//!
64//! # Transient window failures
65//!
66//! A tick that is behind head scans several windows, and it reaches head only
67//! if every window succeeds. A provider that fails a fraction of its calls
68//! would therefore stop most long ticks short of head. So [`run_tick`] retries a
69//! failed window in place:
70//! a `get_logs` error that [`is_transient_window_error`] accepts is retried on
71//! the same block range up to [`GET_LOGS_WINDOW_RETRIES`] times, after
72//! [`GET_LOGS_WINDOW_RETRY_DELAY`] each. A retry that succeeds is a plain
73//! success: no route's `on_backoff` fires and no route is marked down. Each
74//! retry logs its cause at `info` and fires the `on_window_retry` hook (the
75//! `decdn_chain_get_logs_retries_total` counter), so an unreliable provider
76//! stays visible although the retries keep it out of watcher downtime.
77//!
78//! Three kinds of failure are not retried in place. A range rejection takes the
79//! shrink path above. An error that [`is_permanent_rpc_error`] calls
80//! deterministic fails the same way on every attempt. A rate limit
81//! ([`is_rate_limit`]) goes to the loop's exponential backoff, because a
82//! fixed-delay retry spends more of a quota that is already used up.
83//!
84//! The retries lengthen a failing window. A provider that hangs costs each
85//! window `GET_LOGS_WINDOW_RETRIES + 1` per-call timeouts plus the retry
86//! sleeps (34 s at the 10 s default timeout) before the window is deferred.
87//!
88//! # Deferred windows
89//!
90//! A provider's transient failures cluster in time, so a window that fails
91//! every retry often fails again a few seconds later. Failing the tick then
92//! would mark every route down and count a watcher restart for an outage that
93//! loses no event. So [`run_tick`] defers such a window instead: it ends the
94//! tick there, keeps the progress of the windows before it, and the next tick
95//! resumes at the deferred window's start. Each deferral logs its cause at
96//! `info` and fires the `on_window_deferred` hook (the
97//! `decdn_chain_get_logs_deferred_total` counter).
98//!
99//! A deferred tick that completed a window is a success that ended early: it
100//! reconciles and fires every route's hooks. A deferred tick that completed no
101//! window fires no hook and skips the reconcile, so no tick gauge, freshness
102//! stamp or `on_tick_complete` backstop moves. A stall
103//! clock starts at the first deferred tick and clears only when a tick reaches
104//! head, so progress that keeps falling behind head cannot hide behind the
105//! early successes. A deferred tick fails through [`fail_whole_tick`] once no
106//! tick has reached head for [`GET_LOGS_STALL_BUDGET`].
107//!
108//! The runtime registers each `eth_getLogs` watcher's [`Route`] on one
109//! poller in `build_chain_and_handlers` and spawns it once — one merged loop in
110//! place of five independent per-watcher loops (four when the fee-shares
111//! route is absent: it registers only when the startup `feeRouter()` read
112//! succeeds).
113//!
114//! This module is `pub` only so `Route` can appear in the `pub` watcher
115//! `bootstrap` signatures and the external settlement e2e can drive `spawn`; its
116//! docs still reference the crate-internal collaborators (`ErasedSink`,
117//! `CursorStart`, the `resumable_watcher` cursor vocabulary), so intra-doc links
118//! to those private items are allowed here rather than downgraded to prose.
119#![allow(rustdoc::private_intra_doc_links)]
120
121use std::collections::HashMap;
122use std::num::NonZeroU64;
123use std::sync::Arc;
124use std::time::Duration;
125
126use alloy::primitives::{Address, B256};
127use alloy::providers::Provider;
128use alloy::rpc::types::{Filter, Log};
129use alloy::transports::TransportError;
130use anyhow::{Context, Result};
131use async_trait::async_trait;
132use decdn_client::provider::is_permanent_rpc_error;
133use decdn_common::config::DEFAULT_GET_LOGS_MAX_BLOCK_SPAN;
134use decdn_common::redact::sanitize_err_chain;
135use tokio_util::sync::CancellationToken;
136use tokio_util::task::AbortOnDropHandle;
137use tracing::{debug, error, info, warn};
138
139use crate::rpc_metrics::is_rate_limit;
140
141use super::resumable_watcher::{CursorStart, LogSink, WatcherHandle, WatcherHook, fire};
142use super::shared_head::HeadSource;
143use super::{Shrunk, WindowSpan, timed, window_end};
144use super::{WATCHER_INITIAL_BACKOFF, WATCHER_MAX_BACKOFF};
145
146/// Object-safe adapter over [`LogSink`], so routes with different concrete sink
147/// types live in one `Vec<Box<dyn ErasedSink>>`. `LogSink::apply` /
148/// `on_tick_complete` return `impl Future`, which is not object-safe; this
149/// trait is, and is blanket-implemented for every `LogSink` so no sink writes
150/// it by hand.
151#[async_trait]
152pub(crate) trait ErasedSink: Send {
153    async fn apply(&mut self, log: Log) -> Result<()>;
154    async fn on_tick_complete(&mut self) -> Result<()>;
155    fn on_recovered(&mut self);
156}
157
158#[async_trait]
159impl<S: LogSink> ErasedSink for S {
160    async fn apply(&mut self, log: Log) -> Result<()> {
161        LogSink::apply(self, log).await
162    }
163    async fn on_tick_complete(&mut self) -> Result<()> {
164        LogSink::on_tick_complete(self).await
165    }
166    fn on_recovered(&mut self) {
167        LogSink::on_recovered(self);
168    }
169}
170
171/// A route's sink, resolved to a concrete [`ErasedSink`] inside [`spawn`] once
172/// the poller mints its single shutdown token.
173///
174/// Most routes carry a `Ready` sink built at registration. The blacklist route
175/// is the exception: its sink must observe the poller's own shutdown token (its
176/// re-scope pass polls it between per-hash `eth_call`s so a large deny-set does
177/// not overrun the shutdown deadline), and that token does not exist until
178/// [`spawn`] mints it. Such a route registers a `Factory` that [`spawn`] invokes
179/// with the freshly-minted token — a per-route `make_sink` seam that keeps the
180/// token paired with a live task so it can never be inert (#1236).
181pub(crate) enum SinkSource {
182    Ready(Box<dyn ErasedSink>),
183    Factory(SinkFactory),
184}
185
186/// A per-route sink builder invoked inside [`spawn`] with the poller's
187/// freshly-minted shutdown token — see [`SinkSource::Factory`].
188pub(crate) type SinkFactory = Box<dyn FnOnce(&CancellationToken) -> Box<dyn ErasedSink> + Send>;
189
190impl SinkSource {
191    /// Resolve a `Factory` against the poller's shutdown token; a `Ready` sink
192    /// passes through unchanged. Consumed by value so no placeholder sink is
193    /// needed to swap it out.
194    fn resolved(self, shutdown: &CancellationToken) -> Self {
195        match self {
196            Self::Factory(make) => Self::Ready(make(shutdown)),
197            ready @ Self::Ready(_) => ready,
198        }
199    }
200
201    /// The concrete sink once resolved. `None` for a still-`Factory` source —
202    /// only reachable if [`run`] is driven without going through [`spawn`], as
203    /// the unit tests do, and those always register `Ready` sinks.
204    fn as_erased_mut(&mut self) -> Option<&mut (dyn ErasedSink + 'static)> {
205        match self {
206            Self::Ready(sink) => Some(sink.as_mut()),
207            Self::Factory(_) => None,
208        }
209    }
210}
211
212/// One registered watcher on the multiplexed poller. `addresses`/`topic0s` are
213/// its demux keys; every `(address, topic0)` pair in the cartesian product is a
214/// route key, and every route key must be globally unique across the poller's
215/// routes ([`MultiplexedPollerBuilder::build`] checks this). Each existing
216/// watcher is single-address, so `addresses` is a 1-element vec today, but the
217/// field is a set so a future multi-address watcher needs no reshape.
218pub struct Route {
219    pub(crate) addresses: Vec<Address>,
220    pub(crate) topic0s: Vec<B256>,
221    pub(crate) start: CursorStart,
222    pub(crate) sink: SinkSource,
223    pub(crate) label: &'static str,
224    pub(crate) on_established: Option<WatcherHook>,
225    pub(crate) on_backoff: Option<WatcherHook>,
226    pub(crate) on_tick_success: Option<WatcherHook>,
227    pub(crate) on_task_panic: Option<WatcherHook>,
228}
229
230impl std::fmt::Debug for Route {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        // The sink and hooks are opaque closures/trait objects; the demux keys
233        // and label are what identify a route in a log line.
234        f.debug_struct("Route")
235            .field("label", &self.label)
236            .field("addresses", &self.addresses)
237            .field("topic0s", &self.topic0s)
238            .finish_non_exhaustive()
239    }
240}
241
242/// A [`Route`]'s per-tick working state: its cursor machinery and sink, plus
243/// the fields `run_tick` mutates each tick. Does not carry `on_task_panic` —
244/// that hook is extracted into [`MultiplexedPoller::panic_hooks`] at build
245/// time, a separate field the [`PanicGuard`] in [`run`] can borrow immutably
246/// for the whole task without conflicting with `run_tick`'s per-tick `&mut`
247/// borrows of `routes` (see that guard's doc for why the split exists).
248struct RouteState {
249    start: CursorStart,
250    sink: SinkSource,
251    label: &'static str,
252    on_established: Option<WatcherHook>,
253    on_backoff: Option<WatcherHook>,
254    on_tick_success: Option<WatcherHook>,
255    /// This route's cursor: `None` until the first tick resolves it (seed or
256    /// checkpoint/head-window floor), `Some` thereafter.
257    cursor: Option<u64>,
258    /// This tick's floor, captured at the start of the tick so the demux gate
259    /// (`log.block_number < tick_floor`) has a stable value to compare against
260    /// even as `cursor` advances window-by-window within the same tick.
261    tick_floor: u64,
262    /// Set when this route's sink (or reconcile) errored this tick; gates the
263    /// demux and the advance/persist step so the route holds its cursor and
264    /// re-scans next tick instead of racing ahead of a failure.
265    errored: bool,
266    /// First-cycle edge tracking for `on_established`.
267    established: bool,
268    /// Set when this route — or the tick as a whole, via `fail_whole_tick` —
269    /// errored on an earlier tick and has not yet been told it recovered;
270    /// consumed by `notify_recovered_routes` on the first tick it comes back
271    /// clean.
272    ///
273    /// Not derived from `!established`: that is also true on the first-ever
274    /// tick, and a sink whose reconcile just ran at bootstrap would then be
275    /// asked to re-read immediately. The condition this records is "errored at
276    /// least once", which a shared-read failure on the very first tick does
277    /// satisfy — that route then gets one redundant re-read, which is safe.
278    recovering: bool,
279}
280
281/// Poller configuration, its resolved routes, and the demux index built once
282/// at [`MultiplexedPollerBuilder::build`].
283pub struct MultiplexedPoller {
284    head: Arc<dyn HeadSource>,
285    routes: Vec<RouteState>,
286    /// `(address, topic0) -> route index`. Built once at `build()`; a log
287    /// whose key is absent is not subscribed by any route and is dropped.
288    key_index: HashMap<(Address, B256), usize>,
289    /// Merged filter over every route's addresses and topic0s, built once at
290    /// `build()`. The block range is set per window in `run_tick`.
291    base_filter: Filter,
292    /// Contract deploy floor — the lower clamp on a rewound `FromCheckpoint`
293    /// resume and the floor a `HeadMinusWindow` start derives from. Always `0`
294    /// today (every route either seeds its cursor from an enumeration or clamps a
295    /// persisted resume that never predates deploy), retained as that floor.
296    from_block: u64,
297    poll_interval: Duration,
298    /// Block span of the next `eth_getLogs` window, learned from the provider:
299    /// it starts at the configured ceiling, shrinks on a range rejection and
300    /// doubles back after a run of accepted windows (see [`WindowSpan`]).
301    span: WindowSpan,
302    /// Called with the new span when it changes, and once with the starting
303    /// span when [`run`] starts (the `decdn_chain_get_logs_span` gauge).
304    on_span_change: Option<SpanHook>,
305    /// Called on every range rejection the poller recognises, including a
306    /// rejected one-block window.
307    on_range_rejection: Option<WatcherHook>,
308    /// Called on every in-tick retry of a transiently failed `get_logs`
309    /// window (see the module doc's "Transient window failures").
310    on_window_retry: Option<WatcherHook>,
311    /// Called each time a window fails every retry and the tick defers it to
312    /// the next tick (see the module doc's "Deferred windows").
313    on_window_deferred: Option<WatcherHook>,
314    /// When the poller stopped reaching head: set by the first deferred tick,
315    /// cleared only by a tick that reaches head. A stall older than
316    /// [`GET_LOGS_STALL_BUDGET`] fails the tick.
317    stalled_since: Option<tokio::time::Instant>,
318    initial_backoff: Duration,
319    /// Ceiling for the shared loop's backoff. One loop serves every route, so
320    /// no route carries its own tighter cap — [`WATCHER_MAX_BACKOFF`] (60s)
321    /// applies to the whole poller.
322    max_backoff: Duration,
323    rpc_call_timeout: Option<Duration>,
324    /// Every route's `(label, on_task_panic)`, extracted out of `routes` at
325    /// build time — see [`RouteState`]'s doc for why they live here instead.
326    panic_hooks: Vec<(&'static str, Option<WatcherHook>)>,
327}
328
329impl std::fmt::Debug for MultiplexedPoller {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        f.debug_struct("MultiplexedPoller")
332            .field("routes", &self.routes.len())
333            .field("poll_interval", &self.poll_interval)
334            .finish_non_exhaustive()
335    }
336}
337
338/// Receives the poller's `eth_getLogs` window span once when the poller starts
339/// and each time the span changes.
340pub(crate) type SpanHook = Box<dyn Fn(u64) + Send + Sync>;
341
342/// Accumulates [`Route`]s and builds a [`MultiplexedPoller`].
343pub struct MultiplexedPollerBuilder {
344    head: Arc<dyn HeadSource>,
345    routes: Vec<Route>,
346    from_block: u64,
347    poll_interval: Duration,
348    max_backfill_span: u64,
349    on_span_change: Option<SpanHook>,
350    on_range_rejection: Option<WatcherHook>,
351    on_window_retry: Option<WatcherHook>,
352    on_window_deferred: Option<WatcherHook>,
353    initial_backoff: Duration,
354    max_backoff: Duration,
355    rpc_call_timeout: Option<Duration>,
356}
357
358impl std::fmt::Debug for MultiplexedPollerBuilder {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        f.debug_struct("MultiplexedPollerBuilder")
361            .field("routes", &self.routes.len())
362            .field("poll_interval", &self.poll_interval)
363            .finish_non_exhaustive()
364    }
365}
366
367impl MultiplexedPollerBuilder {
368    /// Construct with the defaults every route shares: the deploy floor (`0`),
369    /// [`DEFAULT_GET_LOGS_MAX_BLOCK_SPAN`], and the default watcher backoff
370    /// schedule.
371    #[must_use]
372    pub fn new(head: Arc<dyn HeadSource>, poll_interval: Duration) -> Self {
373        Self {
374            head,
375            routes: Vec::new(),
376            from_block: 0,
377            poll_interval,
378            max_backfill_span: DEFAULT_GET_LOGS_MAX_BLOCK_SPAN,
379            on_span_change: None,
380            on_range_rejection: None,
381            on_window_retry: None,
382            on_window_deferred: None,
383            initial_backoff: WATCHER_INITIAL_BACKOFF,
384            max_backoff: WATCHER_MAX_BACKOFF,
385            rpc_call_timeout: None,
386        }
387    }
388
389    /// Register one watcher's route.
390    #[must_use]
391    pub fn route(mut self, route: Route) -> Self {
392        self.routes.push(route);
393        self
394    }
395
396    /// Set the block-span ceiling per `eth_getLogs` window
397    /// (`blockchain.get_logs_max_block_span`). The poller starts there,
398    /// shrinks below it on a provider range rejection and regrows back to it.
399    /// [`Self::build`] rejects `0`.
400    #[must_use]
401    pub const fn max_backfill_span(mut self, span: u64) -> Self {
402        self.max_backfill_span = span;
403        self
404    }
405
406    /// The configured block-span ceiling.
407    #[cfg(test)]
408    pub(crate) const fn span_ceiling(&self) -> u64 {
409        self.max_backfill_span
410    }
411
412    /// Observe the window span: called once when the poller starts and on
413    /// every shrink or regrow.
414    #[must_use]
415    pub(crate) fn on_span_change(mut self, hook: SpanHook) -> Self {
416        self.on_span_change = Some(hook);
417        self
418    }
419
420    /// Observe every provider range rejection the poller recognises.
421    #[must_use]
422    pub(crate) fn on_range_rejection(mut self, hook: WatcherHook) -> Self {
423        self.on_range_rejection = Some(hook);
424        self
425    }
426
427    /// Observe every in-tick retry of a transiently failed `get_logs` window.
428    #[must_use]
429    pub(crate) fn on_window_retry(mut self, hook: WatcherHook) -> Self {
430        self.on_window_retry = Some(hook);
431        self
432    }
433
434    /// Observe every `get_logs` window the poller defers to the next tick
435    /// after it fails every in-tick retry.
436    #[must_use]
437    pub(crate) fn on_window_deferred(mut self, hook: WatcherHook) -> Self {
438        self.on_window_deferred = Some(hook);
439        self
440    }
441
442    /// Build the poller: construct the merged filter and the `(address,
443    /// topic0) -> route` demux index. Returns `Err` if two routes claim the
444    /// same `(address, topic0)` key — two watchers subscribing to the same
445    /// event is a wiring bug, not a runtime condition, so it fails fast at
446    /// startup rather than silently routing every such log to whichever route
447    /// happened to register first.
448    pub fn build(self) -> Result<MultiplexedPoller> {
449        let span_ceiling = NonZeroU64::new(self.max_backfill_span)
450            .context("multiplexed poller max_backfill_span must be at least 1 block")?;
451        let mut key_index = HashMap::new();
452        let mut all_addresses = Vec::new();
453        let mut all_topic0s = Vec::new();
454        for (idx, route) in self.routes.iter().enumerate() {
455            for &address in &route.addresses {
456                for &topic0 in &route.topic0s {
457                    if let Some(&existing) = key_index.get(&(address, topic0)) {
458                        anyhow::bail!(
459                            "duplicate multiplexed poller route key (address={address}, \
460                             topic0={topic0}): routes {existing} and {idx} ({} and {}) both \
461                             claim it",
462                            self.routes.get(existing).map_or("?", |r: &Route| r.label),
463                            route.label,
464                        );
465                    }
466                    key_index.insert((address, topic0), idx);
467                }
468            }
469            all_addresses.extend(route.addresses.iter().copied());
470            all_topic0s.extend(route.topic0s.iter().copied());
471        }
472        // Routes commonly share a contract address (settlement + rate-bounds on
473        // PaymentPool; capacity-bond + slash on CapacityBond), so the merged
474        // filter would otherwise carry duplicate addresses/topic0s — bloating the
475        // `eth_getLogs` request for no benefit. Dedup before building it; the
476        // per-route `key_index` above is what preserves demux correctness, not
477        // the filter's multiplicity. Order is irrelevant to `eth_getLogs`.
478        all_addresses.sort_unstable();
479        all_addresses.dedup();
480        all_topic0s.sort_unstable();
481        all_topic0s.dedup();
482        let base_filter = Filter::new()
483            .address(all_addresses)
484            .event_signature(all_topic0s);
485
486        let mut routes = Vec::with_capacity(self.routes.len());
487        let mut panic_hooks = Vec::with_capacity(self.routes.len());
488        for route in self.routes {
489            panic_hooks.push((route.label, route.on_task_panic));
490            routes.push(RouteState {
491                start: route.start,
492                sink: route.sink,
493                label: route.label,
494                on_established: route.on_established,
495                on_backoff: route.on_backoff,
496                on_tick_success: route.on_tick_success,
497                cursor: None,
498                tick_floor: 0,
499                errored: false,
500                established: false,
501                recovering: false,
502            });
503        }
504
505        Ok(MultiplexedPoller {
506            head: self.head,
507            routes,
508            key_index,
509            base_filter,
510            from_block: self.from_block,
511            poll_interval: self.poll_interval,
512            span: WindowSpan::new(span_ceiling),
513            on_span_change: self.on_span_change,
514            on_range_rejection: self.on_range_rejection,
515            on_window_retry: self.on_window_retry,
516            on_window_deferred: self.on_window_deferred,
517            stalled_since: None,
518            initial_backoff: self.initial_backoff,
519            max_backoff: self.max_backoff,
520            rpc_call_timeout: self.rpc_call_timeout,
521            panic_hooks,
522        })
523    }
524}
525
526/// Fire `on_backoff` for every route (unless `shutdown` is cancelled — the
527/// same edge suppression a successful tick applies) and clear every
528/// route's `established` flag, then hand back `err` unchanged. Used only at
529/// the shared window-scan failure points: the head read, a route's floor
530/// derivation, or a merged `get_logs` call that is not retried, all before any
531/// route-specific step has run; or a stall past [`GET_LOGS_STALL_BUDGET`],
532/// after the completed windows are applied but before the reconcile. No single
533/// route's `errored` flag would otherwise capture such a failure, and every
534/// route is equally "down".
535fn fail_whole_tick(
536    poller: &mut MultiplexedPoller,
537    shutdown: &CancellationToken,
538    err: anyhow::Error,
539) -> anyhow::Error {
540    if !shutdown.is_cancelled() {
541        for r in &poller.routes {
542            fire(r.on_backoff.as_ref());
543        }
544    }
545    for r in &mut poller.routes {
546        r.established = false;
547        r.recovering = true;
548    }
549    err
550}
551
552/// Step 1: resolve every route's floor for this tick (its cursor, or a
553/// first-tick `initial_from`) and return the union scan range's lower bound
554/// (`min` over resolved cursors, or `to` if every route is already there — an
555/// idle tick). A `FromCheckpoint` load error is retryable and propagates
556/// (settlement's #751/#762 guard); the caller fails the whole tick on it.
557fn resolve_route_floors(poller: &mut MultiplexedPoller, to: u64) -> Result<u64> {
558    for r in &mut poller.routes {
559        if r.cursor.is_none() {
560            let resolved = match r.start.seed() {
561                Some(seed) => seed,
562                None => r.start.initial_from(poller.from_block, to)?,
563            };
564            r.cursor = Some(resolved);
565        }
566        // Capture the floor for this tick's demux gate; `unwrap_or` never
567        // actually falls through (the branch above always leaves `cursor`
568        // `Some`), kept as the anti-panic-safe idiom rather than `expect`.
569        r.tick_floor = r.cursor.unwrap_or(poller.from_block);
570        r.errored = false;
571    }
572    Ok(poller
573        .routes
574        .iter()
575        .filter_map(|r| r.cursor)
576        .min()
577        .unwrap_or(to))
578}
579
580/// Demux one window's logs by `(address, topic0)`, gated by each route's own
581/// floor for this tick. A sink `Err` isolates that route (see the module doc);
582/// siblings are unaffected.
583async fn demux_window_logs(poller: &mut MultiplexedPoller, logs: Vec<Log>) {
584    for log in logs {
585        let Some(t0) = log.topic0().copied() else {
586            continue;
587        };
588        let Some(&idx) = poller.key_index.get(&(log.address(), t0)) else {
589            continue; // not a subscribed (address, topic0)
590        };
591        let Some(route) = poller.routes.get_mut(idx) else {
592            continue;
593        };
594        if route.errored {
595            continue; // this route re-scans the whole window next tick
596        }
597        // Floor gate: a route whose floor sits above this window's start (it
598        // enumerated at a later snapshot block, or is a sibling still ahead) ignores logs below
599        // its own floor. A `None` block_number applies rather than being
600        // silently dropped.
601        if log.block_number.is_some_and(|b| b < route.tick_floor) {
602            continue;
603        }
604        // The sink borrow is confined to this match so `route.errored` can be
605        // set afterward without aliasing it.
606        let applied = if let Some(sink) = route.sink.as_erased_mut() {
607            sink.apply(log).await
608        } else {
609            // Unreachable in production: `spawn` always resolves every
610            // `SinkSource::Factory` before handing the poller to `run` (see
611            // `SinkSource::as_erased_mut`'s doc). Silently `continue`ing here
612            // would advance this route's cursor past a log it never applied —
613            // a future wiring bug that skips factory resolution would drop
614            // data with no signal, so fail loudly in debug/test builds and
615            // hold the release-build fallback of skipping the log.
616            debug_assert!(
617                false,
618                "route {} has an unresolved SinkSource::Factory at demux time; \
619                 spawn() must resolve every factory before run()",
620                route.label
621            );
622            continue;
623        };
624        if let Err(err) = applied {
625            // Retryable sink error: isolate this route. It holds its cursor
626            // and re-scans; sibling routes keep advancing.
627            route.errored = true;
628            warn!(
629                label = route.label,
630                error = %sanitize_err_chain(&err),
631                "route sink error; isolating and re-scanning this route"
632            );
633        }
634    }
635}
636
637/// Advance + persist per route after a window drains: a route whose floor
638/// this window covered and which did not error moves to `end + 1`.
639fn advance_routes(poller: &mut MultiplexedPoller, end: u64) {
640    for r in &mut poller.routes {
641        if !r.errored && r.tick_floor <= end {
642            r.cursor = Some(end.saturating_add(1));
643            r.start.persist(end);
644        }
645    }
646}
647
648/// End-of-tick reconcile per route (blacklist re-scope — origin blacklisting
649/// rides the blacklist route — capacity-bond/slash resync, rate-bounds and
650/// fee-shares safety-net re-reads). A
651/// reconcile `Err` marks that route errored (holds its cursor, retries).
652async fn reconcile_routes(poller: &mut MultiplexedPoller) {
653    for r in &mut poller.routes {
654        if r.errored {
655            continue;
656        }
657        let reconciled = if let Some(sink) = r.sink.as_erased_mut() {
658            sink.on_tick_complete().await
659        } else {
660            // Unreachable in production; see the matching branch in
661            // `demux_window_logs` for why this asserts instead of silently
662            // skipping the reconcile.
663            debug_assert!(
664                false,
665                "route {} has an unresolved SinkSource::Factory at reconcile time; \
666                 spawn() must resolve every factory before run()",
667                r.label
668            );
669            continue;
670        };
671        if let Err(err) = reconciled {
672            r.errored = true;
673            warn!(label = r.label, error = %sanitize_err_chain(&err), "route reconcile error");
674        }
675    }
676}
677
678/// Tell every route that just came back from an errored tick, before the
679/// reconcile below acts on it.
680///
681/// Ordering is the point: a sink whose reconcile is cadence-gated clears its
682/// clock here and re-reads on this same tick's `on_tick_complete`. Firing from
683/// [`fire_route_hooks`], which runs after the reconcile, would delay that repair
684/// a full tick. Suppressed once shutdown is cancelled, mirroring the established
685/// edge — there is no point forcing a re-read the process is about to abandon.
686fn notify_recovered_routes(poller: &mut MultiplexedPoller, shutdown: &CancellationToken) {
687    if shutdown.is_cancelled() {
688        return;
689    }
690    for r in &mut poller.routes {
691        if r.errored || !r.recovering {
692            continue;
693        }
694        let Some(sink) = r.sink.as_erased_mut() else {
695            // Unreachable in production; see the matching branch in
696            // `demux_window_logs`. The edge is deliberately NOT consumed here —
697            // clearing it would lose the recovery notification for the whole
698            // outage rather than for one tick.
699            debug_assert!(
700                false,
701                "route {} has an unresolved SinkSource::Factory at recovery time; \
702                 spawn() must resolve every factory before run()",
703                r.label
704            );
705            continue;
706        };
707        r.recovering = false;
708        sink.on_recovered();
709    }
710}
711
712/// Fire per-route hooks for this tick's outcome and report whether any route
713/// errored (the caller fails the tick on `true`, driving the loop's backoff).
714fn fire_route_hooks(poller: &mut MultiplexedPoller, shutdown: &CancellationToken) -> bool {
715    let any_errored = poller.routes.iter().any(|r| r.errored);
716    for r in &mut poller.routes {
717        if r.errored {
718            if !shutdown.is_cancelled() {
719                fire(r.on_backoff.as_ref());
720            }
721            r.established = false;
722            r.recovering = true;
723        } else {
724            // Liveness stamps on every successful tick, including idle ones —
725            // a wedged/panicked/exited task is detectable by staleness.
726            fire(r.on_tick_success.as_ref());
727            // The established EDGE is suppressed once shutdown is cancelled;
728            // see `fire_tick_success` in `resumable_watcher` for the fail-open
729            // rationale this mirrors.
730            if !r.established && !shutdown.is_cancelled() {
731                r.established = true;
732                fire(r.on_established.as_ref());
733            }
734        }
735    }
736    any_errored
737}
738
739/// Whether a failed `eth_getLogs` is the provider refusing the window's block
740/// range (or its result count), so a smaller window can succeed. Providers use
741/// different codes for this: dRPC `35 "ranges over 10000 blocks are not
742/// supported"`, Alchemy `-32600 "… up to a 10 block range"`, Infura `-32005
743/// "query returned more than 10000 results"` (also its rate-limit code),
744/// Quicknode `-32614 "eth_getLogs is limited to a 10,000 range"`, Ankr
745/// `"block range is too wide"`. The codes do not agree, so the test is the
746/// message: a JSON-RPC error response that names a range or a result count
747/// *and* a limit ([`LIMIT_WORDS`]). Both parts are required because a false
748/// match still costs throughput until the span doubles back: a load-balanced provider whose backend lags head reports a
749/// `toBlock` past that backend's head with range wording but no limit ("block
750/// range extends beyond current head block", "block number is out of range"),
751/// and a smaller window is not the fix for that. Any other error — a timeout, a
752/// transport failure, a rate limit, an unknown block — leaves the window span
753/// alone, so a transient fault cannot shrink it: it is retried in place
754/// ([`is_transient_window_error`]) or takes the backoff path.
755fn is_range_rejection(err: &anyhow::Error) -> bool {
756    err.chain()
757        .find_map(|cause| cause.downcast_ref::<TransportError>())
758        .and_then(TransportError::as_error_resp)
759        .is_some_and(|resp| {
760            let message = resp.message.to_ascii_lowercase();
761            (message.contains("range") || message.contains("results"))
762                && !message.contains("out of range")
763                && LIMIT_WORDS.iter().any(|word| message.contains(word))
764        })
765}
766
767/// Words that mark a range or result-count message as a limit, not a
768/// head-lag or lookup error. See [`is_range_rejection`].
769const LIMIT_WORDS: [&str; 10] = [
770    "limit",
771    "max",
772    "exceed",
773    "up to",
774    " over ",
775    "more than",
776    "too many",
777    "too wide",
778    "too large",
779    "not supported",
780];
781
782/// How many times [`run_tick`] retries a transiently failed `get_logs` window
783/// before it defers the window to the next tick. At an independent per-call
784/// failure rate of 20 %, all three attempts fail together 0.8 % of the time.
785const GET_LOGS_WINDOW_RETRIES: u32 = 2;
786
787/// The sleep before each in-tick retry of a failed `get_logs` window. A
788/// load-balanced provider whose backend lags head fails a window at the tip
789/// until the backend catches up, so an immediate retry often fails again.
790const GET_LOGS_WINDOW_RETRY_DELAY: Duration = Duration::from_secs(2);
791
792/// How long the poller may defer windows without a tick that reaches head
793/// before a tick fails (see the module doc's "Deferred windows"). It is a
794/// duration, not a tick count, because at the default 7 s poll interval a
795/// deferred tick lasts from about 11 s (the interval plus the retry sleeps) to
796/// about 41 s (three call timeouts plus the sleeps). The clock starts when the
797/// first deferred tick ends and is read when a later one ends, so a poller
798/// that makes no progress enters backoff about 130–165 s after its last tick
799/// stamp: under the 180 s at which the `*WatcherStalled` expressions turn true
800/// (they then hold for 5 m before they fire).
801const GET_LOGS_STALL_BUDGET: Duration = Duration::from_mins(2);
802/// Whether a failed `get_logs` window is worth a retry on the same block
803/// range inside the tick. A range rejection is not: it takes the shrink path
804/// ([`shrink_on_range_rejection`]). An error that [`is_permanent_rpc_error`]
805/// calls deterministic is not either: a revert, an invalid request, method or
806/// params response, an HTTP 4xx other than 408/429 (an expired API key, for
807/// example), or a local serialization or usage error. A rate limit
808/// ([`is_rate_limit`]) is not either: the quota refills on the provider's
809/// schedule, not after [`GET_LOGS_WINDOW_RETRY_DELAY`], so it goes to the
810/// loop's backoff.
811/// Everything else is: a provider's "temporary internal error" or "request
812/// timeout" response, a head-lag error, a transport failure, and a `timed`
813/// timeout, which carries no typed cause.
814fn is_transient_window_error(err: &anyhow::Error) -> bool {
815    !is_range_rejection(err)
816        && err
817            .chain()
818            .find_map(|cause| cause.downcast_ref::<TransportError>())
819            .is_none_or(|cause| !is_permanent_rpc_error(cause) && !is_rate_limit(cause))
820}
821
822/// Handle a failed `get_logs` for the window `[start, end]`. A range rejection
823/// the poller recognises shrinks the span to half the window and returns
824/// `None`: the caller retries the same start block at once. A provider that
825/// caps the `eth_getLogs` range rejects the window outright, and retrying the
826/// same (or, as head moves, a wider) range can never succeed. Any other error
827/// — and a rejected one-block window, which cannot shrink — comes back for the
828/// backoff path.
829fn shrink_on_range_rejection(
830    poller: &mut MultiplexedPoller,
831    start: u64,
832    end: u64,
833    err: anyhow::Error,
834) -> Option<anyhow::Error> {
835    if !is_range_rejection(&err) {
836        return Some(err);
837    }
838    fire(poller.on_range_rejection.as_ref());
839    let Some(Shrunk {
840        span,
841        rejected_probe,
842    }) = poller.span.shrink(start, end)
843    else {
844        return Some(err.context(
845            "the RPC provider rejects even a one-block eth_getLogs window; \
846             it cannot serve the chain watchers — use another provider",
847        ));
848    };
849    let rejected_span = (end - start).saturating_add(1);
850    // A rejected regrow probe is the expected cycle against a known cap; any
851    // other shrink is news about the provider.
852    if rejected_probe {
853        debug!(
854            error = %sanitize_err_chain(&err),
855            rejected_span,
856            span,
857            "RPC provider rejected a regrown eth_getLogs window; shrinking again"
858        );
859    } else {
860        warn!(
861            error = %sanitize_err_chain(&err),
862            rejected_span,
863            span,
864            "RPC provider rejected the eth_getLogs block range; \
865             shrinking the poll window (set blockchain.get_logs_max_block_span \
866             to the lowest span logged while rejections keep coming)"
867        );
868    }
869    report_span(poller, span);
870    None
871}
872
873impl MultiplexedPoller {
874    /// Fire the range-rejection hook once, as [`run_tick`] does on a
875    /// recognised rejection. Lets the runtime test the hook's metric wiring
876    /// without scripting a provider.
877    #[cfg(test)]
878    pub(crate) fn fire_range_rejection_for_test(&self) {
879        fire(self.on_range_rejection.as_ref());
880    }
881
882    /// Fire the window-retry hook once, as [`run_tick`] does on each in-tick
883    /// retry. Lets the runtime test the hook's metric wiring without
884    /// scripting a provider.
885    #[cfg(test)]
886    pub(crate) fn fire_window_retry_for_test(&self) {
887        fire(self.on_window_retry.as_ref());
888    }
889
890    /// Fire the window-deferred hook once, as [`run_tick`] does on each
891    /// deferred window. Lets the runtime test the hook's metric wiring without
892    /// scripting a provider.
893    #[cfg(test)]
894    pub(crate) fn fire_window_deferred_for_test(&self) {
895        fire(self.on_window_deferred.as_ref());
896    }
897}
898
899/// Hand the new window span to the `on_span_change` hook, if any.
900fn report_span(poller: &MultiplexedPoller, span: u64) {
901    if let Some(hook) = poller.on_span_change.as_ref() {
902        hook(span);
903    }
904}
905
906/// What [`fetch_window_logs`] got for one window.
907enum WindowFetch {
908    /// The window's logs.
909    Logs(Vec<Log>),
910    /// A transient error that survived every in-tick retry. The tick defers
911    /// the window to the next tick.
912    Exhausted(anyhow::Error),
913    /// An error that is not retried in place: a range rejection, a permanent
914    /// error or a rate limit.
915    Failed(anyhow::Error),
916}
917
918/// Fetch the logs of the window `[start, end]`, retrying a transient failure
919/// on the same range (see the module doc's "Transient window failures").
920/// Returns `None` when `shutdown` is cancelled during a retry sleep.
921///
922/// Takes the poller's parts rather than `&MultiplexedPoller`: the poller is
923/// not `Sync`, so a shared borrow of it cannot live across the awaits here.
924async fn fetch_window_logs<P: Provider + Clone>(
925    provider: &P,
926    base_filter: &Filter,
927    rpc_call_timeout: Option<Duration>,
928    on_window_retry: Option<&WatcherHook>,
929    start: u64,
930    end: u64,
931    shutdown: &CancellationToken,
932) -> Option<WindowFetch> {
933    let filter = base_filter.clone().from_block(start).to_block(end);
934    let mut retries = 0;
935    loop {
936        let err = match timed(rpc_call_timeout, "get_logs", provider.get_logs(&filter))
937            .await
938            .with_context(|| format!("multiplexed get_logs [{start}, {end}]"))
939        {
940            Ok(logs) => return Some(WindowFetch::Logs(logs)),
941            Err(err) => err,
942        };
943        if !is_transient_window_error(&err) {
944            return Some(WindowFetch::Failed(err));
945        }
946        if retries == GET_LOGS_WINDOW_RETRIES {
947            return Some(WindowFetch::Exhausted(err.context(format!(
948                "gave up after {GET_LOGS_WINDOW_RETRIES} in-tick retries"
949            ))));
950        }
951        retries += 1;
952        info!(
953            error = %sanitize_err_chain(&err),
954            start,
955            end,
956            retry = retries,
957            "eth_getLogs window failed; retrying the same window"
958        );
959        tokio::select! {
960            biased;
961            () = shutdown.cancelled() => return None,
962            () = tokio::time::sleep(GET_LOGS_WINDOW_RETRY_DELAY) => {}
963        }
964        // Counted after the sleep, so a retry that shutdown cancels is not.
965        fire(on_window_retry);
966    }
967}
968
969/// Run one poll tick: read head, resolve each route's floor, scan the merged
970/// `[min(floor), head]` range in windows (one `get_logs` per window), demux
971/// each log to its owning route, then reconcile and fire hooks. A recognised
972/// provider range rejection retries the window at half its length instead of
973/// failing (see the module doc's "Provider range caps"), and a transient
974/// `get_logs` failure retries the same window (see "Transient window
975/// failures"). A window that fails every retry ends the tick early (see
976/// "Deferred windows"). Returns `Err` if the shared head/floor reads fail, if a
977/// `get_logs` window fails in a way that is not retried, if a window is
978/// deferred after no tick has reached head for [`GET_LOGS_STALL_BUDGET`], or
979/// if any route errored
980/// applying a log or reconciling — either way the *loop* backs off, but a
981/// route that itself succeeded this tick has already advanced and persisted.
982async fn run_tick<P: Provider + Clone>(
983    provider: &P,
984    poller: &mut MultiplexedPoller,
985    shutdown: &CancellationToken,
986) -> Result<()> {
987    let to = match poller.head.head().await.context("read head block") {
988        Ok(to) => to,
989        Err(err) => return Err(fail_whole_tick(poller, shutdown, err)),
990    };
991
992    let from = match resolve_route_floors(poller, to) {
993        Ok(from) => from,
994        Err(err) => return Err(fail_whole_tick(poller, shutdown, err)),
995    };
996
997    // If every route is already at/above head this is an idle tick: no
998    // `get_logs`, but the reconcile below still runs every route's
999    // `on_tick_complete`.
1000    let mut start = from;
1001    let mut progressed = false;
1002    let mut deferred: Option<anyhow::Error> = None;
1003    while start <= to {
1004        // Yield between windows so a large merged backfill does not block
1005        // graceful shutdown (mirrors the per-window shutdown check).
1006        if shutdown.is_cancelled() {
1007            return Ok(());
1008        }
1009        let end = window_end(start, to, poller.span.current());
1010        let fetched = fetch_window_logs(
1011            provider,
1012            &poller.base_filter,
1013            poller.rpc_call_timeout,
1014            poller.on_window_retry.as_ref(),
1015            start,
1016            end,
1017            shutdown,
1018        )
1019        .await;
1020        let Some(fetched) = fetched else {
1021            // Shutdown ends the tick as the per-window check above does.
1022            return Ok(());
1023        };
1024        let logs = match fetched {
1025            WindowFetch::Logs(logs) => logs,
1026            WindowFetch::Exhausted(err) => {
1027                fire(poller.on_window_deferred.as_ref());
1028                info!(
1029                    error = %sanitize_err_chain(&err),
1030                    start,
1031                    end,
1032                    "eth_getLogs window failed on every retry; deferring it to the next tick"
1033                );
1034                deferred = Some(err);
1035                break;
1036            }
1037            WindowFetch::Failed(err) => match shrink_on_range_rejection(poller, start, end, err) {
1038                // Retry the same start block with the smaller window.
1039                None => continue,
1040                Some(err) => return Err(fail_whole_tick(poller, shutdown, err)),
1041            },
1042        };
1043        demux_window_logs(poller, logs).await;
1044        advance_routes(poller, end);
1045        progressed = true;
1046        if let Some(span) = poller.span.record_success() {
1047            debug!(
1048                span,
1049                "eth_getLogs windows accepted; growing the poll window"
1050            );
1051            report_span(poller, span);
1052        }
1053        if end >= to {
1054            break;
1055        }
1056        start = end + 1;
1057    }
1058
1059    // A deferred tick did not reach head. The stall clock runs from the first
1060    // such tick and clears only when a tick reaches head, so sparse progress
1061    // cannot hide a lag that keeps growing. Past the budget the tick fails,
1062    // with the completed windows already persisted.
1063    if let Some(err) = deferred {
1064        let since = *poller
1065            .stalled_since
1066            .get_or_insert_with(tokio::time::Instant::now);
1067        if since.elapsed() >= GET_LOGS_STALL_BUDGET {
1068            let err = err.context(format!(
1069                "no poll tick reached head for {} s",
1070                GET_LOGS_STALL_BUDGET.as_secs()
1071            ));
1072            return Err(fail_whole_tick(poller, shutdown, err));
1073        }
1074        // A deferred tick that completed no window fires no hook: nothing moved,
1075        // so no tick gauge or freshness stamp is earned.
1076        if !progressed {
1077            return Ok(());
1078        }
1079    } else {
1080        poller.stalled_since = None;
1081    }
1082
1083    notify_recovered_routes(poller, shutdown);
1084    reconcile_routes(poller).await;
1085    if fire_route_hooks(poller, shutdown) {
1086        anyhow::bail!("one or more routes errored this tick"); // drives the loop's backoff sleep
1087    }
1088    Ok(())
1089}
1090
1091/// Fires every route's `on_task_panic` and logs at `error!` iff the enclosing
1092/// [`run`] is unwinding on a panic (mirrors
1093/// `resumable_watcher::PanicGuard`, #1316). The poller task is held
1094/// only by an [`AbortOnDropHandle`] that nothing awaits, so a panic is otherwise
1095/// discarded with no log, counter, or restart; this guard is the only thing
1096/// that still runs on the unwind.
1097///
1098/// Borrows `hooks` — a `Vec` [`run`] moves out of the poller's
1099/// `panic_hooks` field via `mem::take` *before* the tick loop starts — rather
1100/// than reading `poller.panic_hooks` directly, so this immutable borrow can
1101/// live for the whole function alongside the loop's `&mut poller` uses without
1102/// the borrow checker treating them as aliasing the same value.
1103struct PanicGuard<'a> {
1104    hooks: &'a [(&'static str, Option<WatcherHook>)],
1105}
1106
1107impl Drop for PanicGuard<'_> {
1108    fn drop(&mut self) {
1109        if std::thread::panicking() {
1110            for (label, hook) in self.hooks {
1111                let hook_ref = hook.as_ref();
1112                // A second panic here (inside an unwind) would abort the
1113                // process; hooks are contractually panic-free, but this guard
1114                // fires an arbitrary caller-supplied closure, so catch
1115                // defensively (mirrors `resumable_watcher::PanicGuard`).
1116                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fire(hook_ref)));
1117                error!(
1118                    label = *label,
1119                    "multiplexed poller task PANICKED; nothing awaits this task, so this is its \
1120                     only trace for this route"
1121                );
1122            }
1123        }
1124    }
1125}
1126
1127/// Drive every registered route from one merged `eth_getLogs` polling loop
1128/// until `shutdown` is cancelled. Uses `tokio::time::interval` with
1129/// `MissedTickBehavior::Delay`, a `biased` select on `shutdown.cancelled()`
1130/// (flush every route's checkpoint and return) vs `ticker.tick()`, backoff
1131/// reset on `Ok`, and bounded exponential backoff on `Err`. The `Err` arm
1132/// itself fires no hook — `run_tick` already fired every route's hooks (or, on
1133/// a shared-read failure, [`fail_whole_tick`] fired all of them) — it only
1134/// sleeps.
1135pub(crate) async fn run<P>(provider: P, mut poller: MultiplexedPoller, shutdown: CancellationToken)
1136where
1137    P: Provider + Clone,
1138{
1139    // Moved out of `poller` so the guard's borrow does not alias the `&mut
1140    // poller` used throughout the loop below; see `PanicGuard`'s doc.
1141    let panic_hooks = std::mem::take(&mut poller.panic_hooks);
1142    let _panic_guard = PanicGuard {
1143        hooks: &panic_hooks,
1144    };
1145
1146    report_span(&poller, poller.span.current());
1147    let mut backoff = poller.initial_backoff;
1148    let mut ticker = tokio::time::interval(poller.poll_interval);
1149    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1150    loop {
1151        tokio::select! {
1152            biased;
1153            () = shutdown.cancelled() => {
1154                for r in &poller.routes {
1155                    r.start.flush();
1156                }
1157                return;
1158            }
1159            _ = ticker.tick() => {}
1160        }
1161        match run_tick(&provider, &mut poller, &shutdown).await {
1162            Ok(()) => {
1163                backoff = poller.initial_backoff;
1164            }
1165            Err(err) => {
1166                warn!(
1167                    error = %sanitize_err_chain(&err),
1168                    backoff_secs = backoff.as_secs(),
1169                    "multiplexed poller tick error; restarting after backoff"
1170                );
1171                tokio::select! {
1172                    biased;
1173                    () = shutdown.cancelled() => {
1174                        for r in &poller.routes {
1175                            r.start.flush();
1176                        }
1177                        return;
1178                    }
1179                    () = tokio::time::sleep(backoff) => {}
1180                }
1181                backoff = (backoff * 2).min(poller.max_backoff);
1182            }
1183        }
1184    }
1185}
1186
1187/// Mint the shutdown token, spawn [`run`], and return the owning
1188/// [`WatcherHandle`], the only pairing of a live task with its shutdown token
1189/// (#1236).
1190pub fn spawn<P>(provider: P, mut poller: MultiplexedPoller) -> WatcherHandle
1191where
1192    P: Provider + Clone + 'static,
1193{
1194    let shutdown = CancellationToken::new();
1195    // Resolve each route's sink against the freshly-minted token now that it
1196    // exists: a `Factory` (blacklist) is built here so its sink observes the
1197    // very token this handle cancels; a `Ready` sink passes through.
1198    let routes = std::mem::take(&mut poller.routes);
1199    poller.routes = routes
1200        .into_iter()
1201        .map(|rs| RouteState {
1202            sink: rs.sink.resolved(&shutdown),
1203            ..rs
1204        })
1205        .collect();
1206    let task = AbortOnDropHandle::new(tokio::spawn(run(provider, poller, shutdown.clone())));
1207    WatcherHandle::new(shutdown, task)
1208}
1209
1210#[cfg(test)]
1211mod tests;