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;