Skip to main content

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;