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;