Skip to main content

decdn_node/chain_events/
boot_retry.rs

1//! In-process retry for the boot-time chain reads (#2159).
2//!
3//! `build_chain_and_handlers` runs four fail-closed chain bootstraps in
4//! sequence: the `CapacityBond` registry snapshot, the slash-detection
5//! enumeration, the `PaymentPool` `usdc()` self-check and the `ContentBlacklist`
6//! deny-set enumeration with its enforcement pass. Each one must succeed before
7//! the node serves, and a boot is only as likely to succeed as the product of
8//! its reads. A single transient provider error therefore must not end the
9//! process: each bootstrap retries its read phase through `BootRetry::run` and
10//! fails only on a deterministic fault or once the shared boot deadline passes.
11//!
12//! The best-effort fee-share reads (`fee_shares::seed_from_chain`) run between
13//! the slash and `usdc()` reads. They retry the same way but fall back to the
14//! floor share instead of failing boot, and they run on a short sub-budget
15//! (`BootRetry::capped`) so a flapping provider cannot spend the deadline the
16//! later fail-closed reads need.
17//!
18//! Startup stays fail-closed. A retry delays readiness; it never opens the ALPN
19//! router before the deny-set is loaded and enforced.
20//!
21//! Three boot-time facts shape the loop:
22//!
23//! - No signal handler exists yet, so SIGTERM or SIGINT ends a retrying boot at
24//!   once. The only durable writes a boot attempt makes are sticky, idempotent
25//!   cache evictions; everything else it sets is in memory. An abrupt exit
26//!   therefore needs no teardown.
27//! - [`SharedHead`](super::shared_head::SharedHead) caches a failed head read
28//!   for half the poll interval. Retries inside that window replay the cached
29//!   error — the first two at the default 7 s poll interval — so the cost is at
30//!   most one cache TTL of the budget.
31//! - The metrics listener binds after these reads, so
32//!   `decdn_chain_boot_read_retries_total` is scrapeable only once boot
33//!   completes. The per-attempt `WARN` is the live signal during boot.
34
35use std::future::Future;
36use std::sync::Arc;
37use std::time::Duration;
38
39use anyhow::Result;
40use decdn_client::provider::{is_permanent_contract_error, is_permanent_rpc_error};
41use decdn_common::redact::sanitize_err_chain;
42use tokio::time::Instant;
43use tracing::warn;
44
45use super::{WATCHER_INITIAL_BACKOFF, WATCHER_MAX_BACKOFF};
46use crate::metrics::Metrics;
47
48/// The boot deadline: a wall-clock window that starts at [`BootRetry::new`] and
49/// covers every boot chain read, including its successful attempts and the
50/// other bring-up work between them. A provider that stays down for the rest of
51/// this window is treated as dead: the daemon exits, and the supervisor
52/// (systemd, Kubernetes) surfaces it.
53pub(crate) const BOOT_CHAIN_RETRY_BUDGET: Duration = Duration::from_mins(10);
54
55/// A deterministic fault the node detects itself, such as a decoder that
56/// returns mismatched array lengths or a cache that cannot evict. A boot read
57/// never retries it.
58#[derive(Debug)]
59pub(crate) struct BootFault(pub(crate) String);
60
61impl std::fmt::Display for BootFault {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.write_str(&self.0)
64    }
65}
66
67impl std::error::Error for BootFault {}
68
69/// The shared deadline for the boot-time chain reads. Build one per boot and
70/// pass it by reference to every bootstrap. See the [module docs](self).
71#[derive(Debug)]
72pub struct BootRetry {
73    started: Instant,
74    deadline: Instant,
75    budget: Duration,
76    metrics: Arc<Metrics>,
77}
78
79impl BootRetry {
80    /// Start the clock: the deadline is `budget` from now. A retry sleep never
81    /// ends past it, so a budget under the first backoff (1 s) makes every
82    /// `run` a single attempt.
83    #[must_use]
84    pub fn new(budget: Duration, metrics: Arc<Metrics>) -> Self {
85        let started = Instant::now();
86        // A budget too large to add is effectively unbounded; a far-future
87        // deadline keeps that without the overflow panic.
88        let deadline = started
89            .checked_add(budget)
90            .unwrap_or_else(|| started + Duration::from_secs(u64::from(u32::MAX)));
91        Self {
92            started,
93            deadline,
94            budget,
95            metrics,
96        }
97    }
98
99    /// A deadline that allows no retry: every `run` makes one attempt.
100    #[must_use]
101    pub fn single_attempt(metrics: Arc<Metrics>) -> Self {
102        Self::new(Duration::ZERO, metrics)
103    }
104
105    /// A sub-budget for a best-effort read: the earlier of this deadline and
106    /// `cap` from now. A best-effort read that retries on it cannot spend the
107    /// time the later fail-closed reads need.
108    #[must_use]
109    pub(crate) fn capped(&self, cap: Duration) -> Self {
110        let now = Instant::now();
111        let deadline = now
112            .checked_add(cap)
113            .map_or(self.deadline, |d| d.min(self.deadline));
114        Self {
115            started: self.started,
116            deadline,
117            budget: deadline.saturating_duration_since(now),
118            metrics: Arc::clone(&self.metrics),
119        }
120    }
121
122    /// Run `attempt` until it succeeds, fails with a permanent error
123    /// ([`is_permanent_boot_error`]), or the next backoff would end past the
124    /// boot deadline. Each retry logs a `WARN`, bumps
125    /// `decdn_chain_boot_read_retries_total`, and sleeps on the watcher backoff
126    /// schedule (1 s doubling to 60 s). The deadline bounds the sleeps, not the
127    /// attempts: the last attempt can run past it by its own duration. That is
128    /// deliberate. A failing attempt already ends at its first failed read, and
129    /// each read is bounded by the per-call RPC timeout, so a stalled provider
130    /// cannot stretch an attempt. Only a provider that keeps answering can: a
131    /// slow but healthy read of a large deny-set, which a hard deadline would
132    /// turn into an exit. Fail-closed does not depend on the deadline — the
133    /// router opens only after a clean enforcement pass, however long it takes.
134    ///
135    /// # Errors
136    ///
137    /// Returns a permanent error with a context that says it was not retried,
138    /// or the last transient error with a context that names the attempt count
139    /// and the spent budget.
140    pub(crate) async fn run<T, F, Fut>(&self, what: &'static str, mut attempt: F) -> Result<T>
141    where
142        F: FnMut() -> Fut,
143        Fut: Future<Output = Result<T>>,
144    {
145        let start = Instant::now();
146        let mut backoff = WATCHER_INITIAL_BACKOFF;
147        let mut attempts: u32 = 0;
148        loop {
149            attempts = attempts.saturating_add(1);
150            let err = match attempt().await {
151                Ok(v) => return Ok(v),
152                Err(err) => err,
153            };
154            if is_permanent_boot_error(&err) {
155                return Err(err.context(format!("{what}: deterministic failure, not retried")));
156            }
157            if Instant::now() + backoff > self.deadline {
158                let elapsed = start.elapsed();
159                let boot_elapsed = self.started.elapsed();
160                let budget = self.budget;
161                return Err(err.context(format!(
162                    "{what}: gave up after {attempts} attempts in {elapsed:?}; the shared \
163                     boot chain-read budget of {budget:?} is spent ({boot_elapsed:?} since \
164                     the boot reads began)"
165                )));
166            }
167            warn!(
168                read = what,
169                attempt = attempts,
170                backoff_secs = backoff.as_secs(),
171                error = %sanitize_err_chain(&err),
172                "boot chain read failed; retrying after backoff"
173            );
174            self.metrics.chain_boot_read_retried();
175            tokio::time::sleep(backoff).await;
176            backoff = backoff.saturating_mul(2).min(WATCHER_MAX_BACKOFF);
177        }
178    }
179}
180
181/// Whether a boot-time chain read failed deterministically, so a retry cannot
182/// succeed.
183///
184/// Walks the error chain for the first typed cause:
185///
186/// - a [`BootFault`] is permanent;
187/// - an `alloy::contract::Error` or a `TransportError` is classified by the
188///   classifier the client's registry discovery shares
189///   ([`is_permanent_contract_error`], [`is_permanent_rpc_error`]). The head
190///   read keeps its typed cause through the `SharedHead` cache, so this covers
191///   the head read that opens the registry, slash and blacklist bootstraps.
192///
193/// An error with no typed cause is transient. That covers a
194/// `chain_events::timed` timeout and the blacklist enumeration's count checks,
195/// which an inconsistent RPC view of the pinned block can trip. A response
196/// that is not JSON-RPC at all (`DeserError`) is transient as well: a proxy
197/// under load can return one, and the budget bounds a URL that always does.
198fn is_permanent_boot_error(err: &anyhow::Error) -> bool {
199    err.chain()
200        .find_map(|cause| {
201            if cause.is::<BootFault>() {
202                Some(true)
203            } else if let Some(e) = cause.downcast_ref::<alloy::contract::Error>() {
204                Some(is_permanent_contract_error(e))
205            } else {
206                cause
207                    .downcast_ref::<alloy::transports::TransportError>()
208                    .map(is_permanent_rpc_error)
209            }
210        })
211        .unwrap_or(false)
212}
213
214#[cfg(test)]
215#[allow(clippy::unwrap_used, clippy::panic)]
216mod tests;