decdn_node/dht/chain_staker_set.rs
1//! Chain-backed [`StakerSet`] implementation.
2//!
3//! This module owns the **projection** only: the cached `NodeId → active?` set
4//! and its accessors. The `CapacityBond` enumeration and
5//! the event watcher that keep it current live in
6//! [`crate::dht::capacity_bond_registry`] (#1110), which builds this via
7//! `ChainStakerSet::from_parts` — it shares one `getRegisteredNodes` read and one
8//! `eth_getLogs` loop with the node-address directory, since both are derived
9//! from the same contract.
10//!
11//! The cached set is seeded from `CapacityBond.getRegisteredNodes()`, keyed on the
12//! parallel `active[]` the contract computes per entry — the full `isActive`
13//! predicate: registered, bond ≥ minBond, no unbonding, not ejected. The page
14//! itself is the un-filtered `_registeredAddrs` array (the node-address directory
15//! keeps every entry), so the contract returns the predicate alongside it rather
16//! than filtering the page. It then follows the five membership-mutating events,
17//! applying a `StakerChange`
18//! for every observed transition. (Not an intra-doc link: `StakerChange` is
19//! private to `dht` since #1231, and this module doc is public.)
20//!
21//! # Spec mapping
22//!
23//! - ADR 022 §STORE Flow: the receiver checks `holder` is in the
24//! cached active-staker set populated from
25//! `CapacityBond.getRegisteredNodes()`.
26//! - ADR 019 § Step 3.3: bootstrap pattern (initial paginated
27//! `getRegisteredNodes` + event follow — implemented as an `eth_getLogs`
28//! poll, #1106).
29//! - The event set the watcher follows is grounded in
30//! `CapacityBond.sol`'s own write-paths — every contract write
31//! that flips the canonical `isActive` predicate is mirrored by an
32//! event there.
33//!
34//! # Failure model
35//!
36//! Bootstrap RPC failure → the registry bootstrap propagates the error (the
37//! runtime treats it as fatal; the DHT cannot function without a staker set).
38//!
39//! Watcher RPC failure (mid-run) → the shared loop logs at `warn!`, sleeps for an
40//! exponentially-growing backoff (1s → 60s cap), and re-polls. The cursor is
41//! retained across the backoff, so the next `eth_getLogs` tick re-scans
42//! `[cursor, head]` and re-applies any membership event that landed during the
43//! outage — no stream-level drift window.
44//!
45//! A narrower drift source: an operator-indexed event whose follow-up
46//! `nodeIdOf(operator)` RPC fails is dropped (the membership change is
47//! lost) without tripping the stream backoff. That case bumps no
48//! restart/down-seconds metric, so it is surfaced separately by
49//! `decdn_staker_set_watcher_resolve_failures_total` (#788).
50//!
51//! This drift window is dashboard-visible and alertable via four
52//! metrics (#783, #788), distinct from `decdn_rpc_healthy` (which tracks
53//! the reachability watchdog, not this task):
54//! - `decdn_staker_set_watcher_restarts_total` — distinct drift windows;
55//! bumped once on the *edge* from a healthy cycle into the error state,
56//! not once per backoff iteration of one continuous outage.
57//! - `decdn_staker_set_watcher_down_seconds` — true downtime: reads `0`
58//! for the whole life of any established cycle (however long/quiet) and
59//! climbs only while the loop is in error/backoff between a failed cycle
60//! and the next success, so an alert fires on a *sustained* outage.
61//! - `decdn_staker_set_watcher_resolve_failures_total` — operator-indexed
62//! events (`Reinstated` / `UnbondingRequested`) dropped because the
63//! follow-up `nodeIdOf` RPC failed; a nonzero rate is silent-drift risk
64//! that no stream-level restart would otherwise surface.
65//! - `decdn_staker_set_active_count` — current cached active-set size,
66//! to spot a frozen or collapsed cache.
67//!
68//! Two more cover the repair rather than the drift:
69//! - `decdn_capacity_bond_registry_resync_failures_total` — re-enumerations
70//! that could not read chain state and kept the previous projections.
71//! - `decdn_capacity_bond_registry_last_resync_timestamp_seconds` — when the
72//! last one succeeded; the signal that catches a repair being *skipped*,
73//! which emits nothing at all.
74//!
75//! Since #1110 these describe the one shared `capacity-bond` loop, which also
76//! feeds the bindings projection — so they are that loop's health for both, and
77//! there is no separate node-address watcher family to correlate against
78//! (#1231).
79//!
80//! Re-enumeration is what repairs the drift sources above:
81//! `capacity_bond_registry` re-derives the whole projection from
82//! `getRegisteredNodes`, build-then-swap, so a dropped `nodeIdOf` change is
83//! corrected without waiting for the next event for that operator. Two triggers
84//! drive it. The cadence runs it every `REGISTRY_RESYNC_INTERVAL`. The recovery
85//! edge runs it on the tick the route comes back from an errored one, through
86//! `LogSink::on_recovered`, which is what covers the outage case the cadence
87//! handles worst: `reconcile_routes` skips a route that errored this tick, so
88//! during the outage the cadence repair does not run at all, and a resync whose
89//! own read fails stamps the clock anyway and defers a further interval.
90//!
91//! Neither trigger is a ceiling, for two reasons worth knowing before reading
92//! the metrics as one. The recovery edge fires on a route-level outage — a tick
93//! that errored — and a dropped `nodeIdOf` does not error the route, since
94//! `on_operator_change` returns `Ok` by design; a change lost that way with the
95//! poll otherwise healthy still waits for the cadence. And the re-enumeration
96//! the edge forces can itself fail, which stamps the clock and defers the next
97//! attempt a further interval. So the window closes on the first *successful*
98//! re-enumeration after recovery, not on recovery itself. The metrics above size
99//! that window; the two registry metrics report whether the repair is landing.
100
101use std::collections::HashSet;
102use std::sync::{Arc, RwLock};
103
104use crate::dht::chain_projection::{ChainProjection, mutate_gauged};
105use crate::dht::routing::NodeId;
106use crate::dht::staker_set::StakerSet;
107use crate::metrics::Metrics;
108
109/// Names the projection in the poison-recovery `warn!` and gauge helpers.
110const LABEL: &str = "ChainStakerSet active set";
111
112/// One membership transition, as [`apply_change`] applies it to the cached set.
113///
114/// `capacity_bond_registry` builds one of these for every observed
115/// `CapacityBond` event that flips the canonical `isActive` predicate
116/// (`NodeRegistered` / `NodeDeregistered` / `NodeAutoEjected` / `Reinstated` /
117/// `UnbondingRequested`).
118///
119/// Internal to `dht`, and it lives here rather than in `staker_set` because
120/// `ConfigStakerSet` has no use for it: the only producer
121/// (`capacity_bond_registry`) and the only consumer (`apply_change`, below) are
122/// both on the chain-backed path.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(super) enum StakerChange {
125 /// `node_id` joined the active set (was absent, now present).
126 Active(NodeId),
127 /// `node_id` left the active set (was present, now absent).
128 Inactive(NodeId),
129}
130
131/// Chain-backed staker set. Cheap to clone via the shared inner
132/// [`Arc`]; the runtime holds one `Arc<dyn StakerSet>` and consumers
133/// access the cached set through it. The background loop that keeps the
134/// cache fresh is the single shared multiplexed-poller task the runtime
135/// owns, so a node-restart cycle never leaks chain-poll tasks.
136#[derive(Debug)]
137pub struct ChainStakerSet {
138 proj: ChainProjection<HashSet<NodeId>>,
139}
140
141impl ChainStakerSet {
142 /// Assemble from the active-set state owned by `capacity_bond_registry`,
143 /// which does the enumeration and registers the shared poller route.
144 pub(super) const fn from_parts(active: Arc<RwLock<HashSet<NodeId>>>) -> Self {
145 Self {
146 proj: ChainProjection::from_parts(active, LABEL),
147 }
148 }
149}
150
151impl StakerSet for ChainStakerSet {
152 fn is_active(&self, node_id: &NodeId) -> bool {
153 self.proj.read(|s| s.contains(node_id))
154 }
155
156 fn active_nodes(&self) -> Vec<NodeId> {
157 self.proj.read(|s| s.iter().copied().collect())
158 }
159
160 fn len(&self) -> usize {
161 self.proj.read(HashSet::len)
162 }
163}
164
165/// Apply a change to the active set. Returns whether membership actually
166/// moved — `false` for an idempotent no-op (a re-insert, or a remove of an
167/// absent id), which a re-scanned `eth_getLogs` window produces routinely.
168///
169/// The bool mirrors the `HashSet::insert` / `HashSet::remove` contract this
170/// function dispatches to, and is computed anyway to gate the
171/// `staker_set_active_count` republish below. Callers may ignore it (hence no
172/// `#[must_use]`) — every production call site does, since an idempotent no-op
173/// is a routine outcome rather than an error. In production the mutation-gated
174/// gauge is the observable signal; the bool is what the idempotence tests below
175/// assert on.
176pub(super) fn apply_change(
177 active: &RwLock<HashSet<NodeId>>,
178 metrics: &Arc<Metrics>,
179 change: StakerChange,
180) -> bool {
181 mutate_gauged(
182 active,
183 LABEL,
184 |set| {
185 let mutated = match change {
186 StakerChange::Active(id) => set.insert(id),
187 StakerChange::Inactive(id) => set.remove(&id),
188 };
189 mutated.then_some(set.len())
190 },
191 |size| metrics.staker_set_active_count(size),
192 )
193}
194
195#[cfg(test)]
196#[allow(
197 clippy::unwrap_used,
198 clippy::expect_used,
199 clippy::panic,
200 clippy::indexing_slicing
201)]
202mod tests;