decdn_node/chain_events/shared_head.rs
1//! One shared, TTL-cached `eth_blockNumber` read for every chain watcher.
2//!
3//! Without a shared read, every tick would cost one `eth_blockNumber` *per
4//! watcher* — ~7 head reads per `event_poll_interval` against a single endpoint,
5//! on top of each watcher's `eth_getLogs`. [`SharedHead`] collapses them: the
6//! first caller within a TTL window issues the RPC, everyone else reads the
7//! cache.
8//!
9//! Two properties matter:
10//!
11//! - **Monotonicity — why a stale head is safe.** [`SharedHead`] only ever
12//! returns a head at-or-before the true head at the call instant; it can never
13//! report a head *ahead* of the chain. Every consumer of the head in
14//! `resumable_watcher` clamps *against* it (`resolve_persisted_start`,
15//! `resolve_head_window_start`, and the `from > to` idle branch), so a head
16//! that lags only ever *widens* an already-idempotent rescan or defers work to
17//! the next tick — it can never open a gap. Note this is a property of the
18//! cache direction, not a coincidence: a cache that could run *ahead* would
19//! skip blocks, which is exactly the #751/#762 hazard (anchoring a durable
20//! checkpoint floor too high). The cost is bounded observation latency (≤ TTL),
21//! not correctness.
22//! - **Single-flight, including failures.** The mutex is held across the RPC, so
23//! concurrent callers queue and then read the fresh entry rather than each
24//! issuing their own read. Failures are cached for the same TTL, which is what
25//! keeps a wedged provider from serializing timeouts: without it, N watchers
26//! queued behind a stalled endpoint would each wait the full
27//! `chain_events::DEFAULT_RPC_CALL_TIMEOUT` in turn, so the last one's tick — and
28//! therefore its `*_backoff_started` gauge — would be delayed by N × 10s. With
29//! it, one caller pays the timeout and the rest fail instantly, so every
30//! watcher enters backoff at the same moment.
31
32use std::sync::Arc;
33use std::time::Duration;
34
35use alloy::eips::BlockId;
36use alloy::primitives::Address;
37use alloy::providers::Provider;
38use anyhow::Result;
39use async_trait::async_trait;
40use tokio::time::Instant;
41use tracing::info;
42
43use super::timed;
44
45/// A source of the current chain head block number.
46///
47/// Implementations MAY serve from a short TTL cache. The returned value is always
48/// at-or-before the true head at the call instant and never ahead of it — callers
49/// may rely on that direction (see the module doc), but MUST NOT assume the value
50/// is exactly current. A caller needing the head for a *deadline* decision ("has
51/// block X been reached yet?") rather than a scan upper bound would be delayed by
52/// up to the TTL; no watcher does that today.
53///
54/// `Err` keeps the provider's typed cause in its chain. A watcher tick treats
55/// every `Err` as retryable and fails into backoff; a boot read classifies it
56/// (`boot_retry::is_permanent_boot_error`), so a deterministic failure such as a
57/// rejected API key fails boot at once.
58#[async_trait]
59pub trait HeadSource: Send + Sync {
60 /// The current chain head, at or before the true head and never ahead
61 /// of it. `Err` keeps its typed cause in its chain.
62 async fn head(&self) -> Result<u64>;
63}
64
65/// How far below the reported head an enumeration snapshot pins its reads.
66///
67/// A load-balanced RPC reports the head of its freshest upstream, then routes
68/// each call to any upstream. An `eth_call` pinned to that exact block fails on
69/// every upstream that has not reached it yet, while an older block resolves on
70/// all of them. So the reported head is not a block every upstream can serve.
71/// 256 blocks is about 64s on Arbitrum, above the 40–200 block upstream lag seen
72/// behind a hosted balancer (#2164).
73///
74/// The margin is a block count, sized for Arbitrum's ~250ms blocks and a
75/// provider that serves state that far back. A full node that prunes state
76/// within 256 blocks (geth keeps 128) answers every pinned read with a
77/// transient error, so a boot against one spends its retry budget.
78///
79/// The margin costs no correctness. A boot enumeration seeds its tail cursor at
80/// the snapshot block and every sink is idempotent, so the first tail tick
81/// replays `[snapshot, head]` as a harmless overlap. A periodic re-enumeration
82/// keeps every entry the tail changed after the snapshot block (see each
83/// watcher's fold).
84pub const SNAPSHOT_LAG_MARGIN_BLOCKS: u64 = 256;
85
86/// The block an enumeration of `contract` pins its reads to:
87/// [`SNAPSHOT_LAG_MARGIN_BLOCKS`] below the head.
88///
89/// A contract with no code at that block was deployed inside the margin — a
90/// fresh local chain, or a boot right after a deploy. Reads pinned there would
91/// decode an empty return, so the snapshot takes the head instead. That head pin
92/// is exposed to upstream lag again; a failed read retries, and the lagged pin
93/// returns once the chain is `SNAPSHOT_LAG_MARGIN_BLOCKS` past the deploy.
94pub(crate) async fn snapshot_block<P: Provider>(
95 provider: &P,
96 head: &dyn HeadSource,
97 contract: Address,
98) -> Result<u64> {
99 let head = head.head().await?;
100 let lagged = head.saturating_sub(SNAPSHOT_LAG_MARGIN_BLOCKS);
101 let code = timed(
102 None,
103 "eth_getCode",
104 provider
105 .get_code_at(contract)
106 .block_id(BlockId::number(lagged)),
107 )
108 .await?;
109 if code.is_empty() {
110 info!(
111 %contract,
112 lagged,
113 head,
114 "no contract code at the lagged snapshot block; pinning the snapshot at head"
115 );
116 return Ok(head);
117 }
118 Ok(lagged)
119}
120
121/// A cached head read plus the instant it was taken.
122struct CachedHead {
123 at: Instant,
124 /// `Arc` because `anyhow::Error` is not `Clone` and one failed read is
125 /// replayed to every caller that arrives within the TTL.
126 result: std::result::Result<u64, Arc<anyhow::Error>>,
127}
128
129/// A failed head read, shared by every caller inside the TTL.
130///
131/// The failure is kept whole behind an `Arc` (`anyhow::Error` is not `Clone`)
132/// and exposed as this error's [`source`](std::error::Error::source), so a
133/// caller's `err.chain()` still reaches the typed cause — the provider's
134/// `TransportError` — and can classify it.
135#[derive(Debug)]
136struct HeadReadFailed(Arc<anyhow::Error>);
137
138impl std::fmt::Display for HeadReadFailed {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.write_str("chain head read failed")
141 }
142}
143
144impl std::error::Error for HeadReadFailed {
145 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146 Some(&**self.0)
147 }
148}
149
150/// TTL-cached, single-flight [`HeadSource`] over one provider.
151pub struct SharedHead<P> {
152 provider: P,
153 ttl: Duration,
154 rpc_call_timeout: Option<Duration>,
155 cached: tokio::sync::Mutex<Option<CachedHead>>,
156}
157
158/// Hand-written rather than derived: `P` is a provider and carries no `Debug`
159/// bound, and the cached entry sits behind an async mutex this must not block on.
160impl<P> std::fmt::Debug for SharedHead<P> {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 f.debug_struct("SharedHead")
163 .field("ttl", &self.ttl)
164 .field("rpc_call_timeout", &self.rpc_call_timeout)
165 .finish_non_exhaustive()
166 }
167}
168
169impl<P: Provider> SharedHead<P> {
170 /// Cache head reads for half the watcher poll interval.
171 ///
172 /// Half, rather than a full interval, because the watchers are not
173 /// phase-aligned: they bootstrap sequentially and their ticks drift across
174 /// the interval, so it is the TTL — not the single-flight — that collapses
175 /// the reads. A full-interval TTL would let a watcher act on a head an entire
176 /// tick old, silently stretching the effective cadence of a documented knob
177 /// toward 2×; half an interval bounds the added staleness below one tick for
178 /// one extra RPC per interval. It also self-scales: an operator who drops
179 /// `event_poll_interval_ms` to 250 ms for a local anvil gets a 125 ms TTL and
180 /// a near-live head, with no second knob to tune.
181 pub fn new(provider: P, poll_interval: Duration) -> Self {
182 Self::with_ttl(provider, poll_interval / 2, None)
183 }
184
185 /// [`Self::new`] with an explicit TTL and per-call timeout. `Duration::ZERO`
186 /// disables caching (every call re-reads).
187 pub fn with_ttl(provider: P, ttl: Duration, rpc_call_timeout: Option<Duration>) -> Self {
188 Self {
189 provider,
190 ttl,
191 rpc_call_timeout,
192 cached: tokio::sync::Mutex::new(None),
193 }
194 }
195}
196
197#[async_trait]
198impl<P: Provider> HeadSource for SharedHead<P> {
199 async fn head(&self) -> Result<u64> {
200 // Held across the RPC: that IS the single-flight. A caller that queues
201 // here finds the entry already refreshed and returns it, rather than
202 // issuing a duplicate read.
203 let mut guard = self.cached.lock().await;
204 if let Some(entry) = guard.as_ref()
205 && entry.at.elapsed() < self.ttl
206 {
207 return match &entry.result {
208 Ok(head) => Ok(*head),
209 // Every caller inside the TTL gets the same failure, typed
210 // cause included, and renders the same text the first did.
211 Err(err) => Err(HeadReadFailed(Arc::clone(err)).into()),
212 };
213 }
214 let result = timed(
215 self.rpc_call_timeout,
216 "get_block_number",
217 self.provider.get_block_number(),
218 )
219 .await;
220 match result {
221 Ok(head) => {
222 *guard = Some(CachedHead {
223 at: Instant::now(),
224 result: Ok(head),
225 });
226 Ok(head)
227 }
228 Err(err) => {
229 let shared = Arc::new(err);
230 *guard = Some(CachedHead {
231 at: Instant::now(),
232 result: Err(Arc::clone(&shared)),
233 });
234 Err(HeadReadFailed(shared).into())
235 }
236 }
237 }
238}
239
240#[cfg(test)]
241#[allow(
242 clippy::unwrap_used,
243 clippy::expect_used,
244 clippy::panic,
245 clippy::indexing_slicing
246)]
247mod tests;