Skip to main content

decdn_node/dht/
records.rs

1//! Receiver-side DHT record store (ADR 022 §Content Records and TTL).
2//!
3//! When a publisher P sends `StoreRequest { hash: H, holder: P }` and the
4//! handler admits it (rate limit + `holder == authenticated NodeId` +
5//! `StakerSet::is_active(holder)` all pass), the record `(H, P)` is
6//! inserted here with `expiry_us = receive_us + ttl_us` and counted
7//! against P's per-publisher quota.
8//!
9//! # Admission policy (ADR 022 §Content Records and TTL)
10//!
11//! | Limit | Default | Behaviour at cap |
12//! |-------|---------|-------------------|
13//! | Per-publisher records | 100,000 | Hard reject — `StoreAck { accepted: false }` |
14//! | Per-hash providers | 50 | Evict oldest holder for that hash |
15//! | Per-node global records | 1,000,000 | Evict globally-oldest record (only when inserting publisher is below per-publisher cap) |
16//! | Record TTL | 1 hour | Receiver-anchored; refreshed on re-publish |
17//!
18//! The active-staker filter in the handler is the security boundary: a
19//! publisher must be a bonded operator to insert at all, so record spam
20//! carries a stake cost. The per-publisher cap is a fairness bound on top
21//! of that — it keeps one publisher's footprint from dominating a
22//! receiver's store, so the global set stays a broad sample rather than
23//! one node's catalogue. With the cap in place, the global LRU only fires
24//! when *the inserting publisher is below quota* — i.e. a below-quota
25//! publisher is pushing the total over capacity, and evicting the
26//! globally-oldest record (likely from a publisher who has stopped
27//! re-publishing) is the right call.
28//!
29//! # Re-publish semantics
30//!
31//! Re-publish from the same `(holder, hash)` pair refreshes the record
32//! (re-derives `expiry_us` from the new `receive_us`) rather than creating
33//! a second record. The per-publisher count nets out unchanged — the
34//! refresh removes the stale record and re-adds it, so the count is
35//! decremented then re-incremented for the same holder. Per ADR 022
36//! §Content Records and TTL this is what extends record lifetime ahead of
37//! the previous expiry while the holder still has the blob.
38
39use std::collections::{BTreeSet, HashMap};
40
41use crate::dht::routing::NodeId;
42use decdn_protocol::Coverage;
43use decdn_protocol::MAX_PROVIDERS_PER_HASH;
44use decdn_protocol::dht::Provider;
45
46/// 32-byte content hash. Re-exported from the protocol crate's
47/// [`decdn_protocol::ContentHash`] newtype — distinct from [`NodeId`] at the
48/// type level, identical (`[u8; 32]`) on the wire.
49pub use decdn_protocol::ContentHash as Hash;
50
51/// Outcome of a [`RecordStore::insert_at`].
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum InsertOutcome {
54    /// Record was newly inserted.
55    Inserted,
56    /// Record already existed; it was refreshed (re-created at the new
57    /// `receive_us`) rather than duplicated.
58    Refreshed,
59    /// Publisher is at the per-publisher cap — hard reject.
60    RejectedQuotaExceeded,
61}
62
63impl InsertOutcome {
64    /// Whether this outcome should map to `StoreAck { accepted: true }`.
65    #[must_use]
66    pub const fn accepted(self) -> bool {
67        matches!(self, Self::Inserted | Self::Refreshed)
68    }
69}
70
71/// A single `(holder, hash)` record. `receive_us` is the receiver-anchored
72/// wall-clock at acceptance (ADR 022 §Content Records and TTL line 122);
73/// `expiry_us = receive_us + ttl_us` strictly — no monotonic tie-breaker
74/// mixed in, so TTL never drifts with insert volume. `sequence` is the
75/// per-insert monotonic counter used only to break wall-clock collisions
76/// in the [`RecordStore::global_lru`] ordering.
77///
78/// `coverage` holds a `Vec`, so this type is `Clone` rather than `Copy` —
79/// every former implicit-copy site now takes an explicit `.clone()`.
80#[derive(Debug, Clone, PartialEq, Eq)]
81struct HolderEntry {
82    holder: NodeId,
83    receive_us: u64,
84    sequence: u64,
85    expiry_us: u64,
86    coverage: Coverage,
87}
88
89/// Configuration for the record store (ADR 022 §Content Records and TTL).
90#[derive(Debug, Clone)]
91pub struct RecordStoreConfig {
92    /// `Max records per publisher (per receiver)`. ADR 022 default: 100,000.
93    pub max_records_per_publisher: usize,
94    /// `Max records per node`. ADR 022 default: 1,000,000.
95    pub max_records_global: usize,
96    /// `Max providers per hash`. ADR 022 default: 50 — pinned to the
97    /// wire response cap so a fully-populated record can be serialised
98    /// straight into a [`decdn_protocol::FindValueResponse`].
99    pub max_providers_per_hash: usize,
100    /// `Record TTL` in microseconds. ADR 022 default: 1 hour =
101    /// `3_600_000_000` μs.
102    pub ttl_us: u64,
103}
104
105impl Default for RecordStoreConfig {
106    fn default() -> Self {
107        Self {
108            max_records_per_publisher: 100_000,
109            max_records_global: 1_000_000,
110            max_providers_per_hash: MAX_PROVIDERS_PER_HASH,
111            ttl_us: 3_600_000_000,
112        }
113    }
114}
115
116/// Receiver-side DHT record store.
117///
118/// Not internally synchronised — the handler owns it behind a `Mutex` /
119/// `RwLock`, mirroring how `RoutingTable` is composed. The methods are
120/// `&mut self` because every admission path touches multiple indexes
121/// (the primary `(hash → entries)` map, the per-publisher quota count,
122/// and the global LRU ordering); requiring exclusive access keeps those
123/// invariants atomic without internal locking overhead.
124#[derive(Debug)]
125pub struct RecordStore {
126    cfg: RecordStoreConfig,
127    /// Primary: `hash → list of holder entries`.
128    by_hash: HashMap<Hash, Vec<HolderEntry>>,
129    /// Per-publisher record count. Keyed by holder; value is the count
130    /// of distinct hashes that holder currently has records for. Stays
131    /// in sync with `by_hash` via every insert/evict/expire path.
132    by_publisher_count: HashMap<NodeId, usize>,
133    /// Global LRU ordering keyed by `(receive_us, sequence, hash, holder)`.
134    /// `receive_us` is the wall-clock at acceptance; `sequence` is a
135    /// monotonic counter that breaks wall-clock collisions under burst.
136    /// Since `ttl_us` is constant, ordering by this tuple is also
137    /// ordering by `expiry_us` — which is what makes [`RecordStore::gc`]
138    /// an `O(expired)` front-walk rather than an `O(N)` scan.
139    global_lru: BTreeSet<(u64, u64, Hash, NodeId)>,
140    /// Monotonic per-insert counter feeding the `sequence` field of new
141    /// records (and the second component of `global_lru` keys). Kept
142    /// strictly distinct from `receive_us` so the
143    /// `expiry_us = receive_us + ttl_us` invariant from ADR 022 line
144    /// 122 holds — TTL does not drift forward with insert volume.
145    next_sequence: u64,
146}
147
148impl RecordStore {
149    /// Construct an empty store.
150    #[must_use]
151    pub fn new(cfg: RecordStoreConfig) -> Self {
152        Self {
153            cfg,
154            by_hash: HashMap::new(),
155            by_publisher_count: HashMap::new(),
156            global_lru: BTreeSet::new(),
157            next_sequence: 0,
158        }
159    }
160
161    /// Total record count across all publishers.
162    #[must_use]
163    pub fn len(&self) -> usize {
164        self.global_lru.len()
165    }
166
167    /// True iff the store has zero records.
168    #[must_use]
169    pub fn is_empty(&self) -> bool {
170        self.global_lru.is_empty()
171    }
172
173    /// Global record capacity — the configured
174    /// [`RecordStoreConfig::max_records_global`] ceiling. Paired with
175    /// [`Self::len`] to report store utilization in the
176    /// `admin_v1_status` health view (issue #741).
177    #[must_use]
178    pub const fn capacity(&self) -> usize {
179        self.cfg.max_records_global
180    }
181
182    /// Record count for one publisher. Returns 0 for unknown publishers.
183    #[must_use]
184    pub fn publisher_record_count(&self, holder: &NodeId) -> usize {
185        self.by_publisher_count.get(holder).copied().unwrap_or(0)
186    }
187
188    /// Insert or refresh `(holder, hash)` with `receive_us` as the
189    /// receiver-anchored wall-clock at acceptance. Returns the outcome.
190    ///
191    /// The caller is responsible for the upstream admission checks (rate
192    /// limit, `holder == authenticated NodeId`, `StakerSet::is_active`).
193    /// This method enforces only the storage-level invariants (per-publisher
194    /// quota with hard reject, global LRU with eviction, per-hash provider
195    /// cap with oldest-provider eviction).
196    pub fn insert_at(
197        &mut self,
198        holder: NodeId,
199        hash: Hash,
200        coverage: Coverage,
201        receive_us: u64,
202    ) -> InsertOutcome {
203        // Allocate one monotonic sequence per call. Stored on the entry
204        // and used as the second component of the `global_lru` key, so
205        // wall-clock collisions don't collapse two records onto one
206        // BTreeSet slot. Crucially this is NOT mixed into `receive_us`
207        // or `expiry_us` — TTL stays exactly `ttl_us` after wall-clock
208        // acceptance per ADR 022 line 122.
209        let sequence = self.next_sequence;
210        self.next_sequence = self.next_sequence.wrapping_add(1);
211
212        // Refresh path: holder already has a record for this hash. Drop
213        // the stale tri-index entry and re-add at the new timestamp (and
214        // the freshly-reported coverage). `remove_entry` then `add_entry`
215        // is net-zero on the per-publisher count and keeps the tri-index
216        // update atomic, so there is no hand-rolled in-place mutation (and
217        // no `get_mut` corruption branch) to keep in sync.
218        if self.remove_entry(holder, hash).is_some() {
219            self.add_entry(holder, hash, coverage, receive_us, sequence);
220            return InsertOutcome::Refreshed;
221        }
222
223        // New record path. Per-publisher hard cap first — this is the
224        // rule that closes the exhaustion attack.
225        if self.publisher_record_count(&holder) >= self.cfg.max_records_per_publisher {
226            return InsertOutcome::RejectedQuotaExceeded;
227        }
228
229        // Per-hash provider cap, FIRST. If this hash is at the wire cap
230        // we evict its oldest holder and net out at the same global count
231        // — so the global LRU below MUST NOT also fire (otherwise one
232        // insert produces two evictions and leaves the store under-full).
233        // We only read `by_hash` here to pick the victim; the mutation
234        // goes through `remove_entry` so all three indexes move together.
235        // ADR 022 doesn't mandate a per-hash eviction policy — we pick
236        // oldest-receive because the newcomer is most likely still live.
237        let evict_holder = self.by_hash.get(&hash).and_then(|entries| {
238            (entries.len() >= self.cfg.max_providers_per_hash)
239                .then(|| {
240                    entries
241                        .iter()
242                        .min_by_key(|e| e.receive_us)
243                        .map(|e| e.holder)
244                })
245                .flatten()
246        });
247        let grows_global = match evict_holder {
248            Some(old_holder) => {
249                self.remove_entry(old_holder, hash);
250                false
251            }
252            None => true,
253        };
254
255        // Global cap fires only when we're actually about to grow the
256        // store. By construction the inserting publisher is below its
257        // per-publisher cap here (checked above) so global LRU eviction
258        // is safe under ADR 022's two-tier rule.
259        if grows_global && self.global_lru.len() >= self.cfg.max_records_global {
260            self.evict_globally_oldest();
261        }
262
263        self.add_entry(holder, hash, coverage, receive_us, sequence);
264        InsertOutcome::Inserted
265    }
266
267    /// Return the current holders for `hash`, dropping any whose
268    /// `expiry_us <= now_us`. The result is capped at the configured
269    /// per-hash provider count (which equals the wire
270    /// [`MAX_PROVIDERS_PER_HASH`] in production), so the handler can
271    /// serialise the return value directly into a
272    /// [`decdn_protocol::FindValueResponse`].
273    ///
274    /// `now_us` is the requester-side wall-clock — passed in rather
275    /// than read inline so the same store can be exercised under a
276    /// mock clock in tests.
277    pub fn providers_at(&mut self, hash: &Hash, now_us: u64) -> Vec<Provider> {
278        // Scrub expired entries for this hash before returning.
279        self.scrub_hash_expired(hash, now_us);
280        self.by_hash
281            .get(hash)
282            .map(|entries| {
283                entries
284                    .iter()
285                    .map(|e| Provider {
286                        node: e.holder,
287                        coverage: e.coverage.clone(),
288                    })
289                    .collect()
290            })
291            .unwrap_or_default()
292    }
293
294    /// Periodic GC sweep — drops every expired record across the store.
295    /// Returns the number removed.
296    ///
297    /// `ttl_us` is constant for all records, so `global_lru` (ordered by
298    /// `receive_us`) is ALSO ordered by `expiry_us` — the oldest entries
299    /// are always at the front. The sweep pops from `global_lru.first()`
300    /// until it sees a non-expired record and returns, which is
301    /// `O(expired × log N)` rather than the previous `O(N)` full scan.
302    pub fn gc(&mut self, now_us: u64) -> usize {
303        let mut removed = 0usize;
304        while let Some(&key @ (receive_us, _, hash, holder)) = self.global_lru.iter().next() {
305            // `expiry_us = receive_us + ttl_us` by construction. We
306            // recompute it here rather than carrying it in the key so
307            // the BTreeSet ordering doesn't depend on a derived field.
308            let expiry_us = receive_us.saturating_add(self.cfg.ttl_us);
309            if expiry_us > now_us {
310                break;
311            }
312            if self.remove_entry(holder, hash).is_none() {
313                // The tri-index invariant guarantees a `global_lru` key
314                // has a matching `by_hash` entry, so this is unreachable
315                // except under heap corruption. Drop the orphaned key
316                // directly so the front-walk still terminates (never
317                // spins on a key `remove_entry` can't clear).
318                self.global_lru.remove(&key);
319                tracing::error!(
320                    ?holder,
321                    ?hash,
322                    "RecordStore::gc: global_lru key with no by_hash entry; dropping orphan"
323                );
324            }
325            removed += 1;
326        }
327        removed
328    }
329
330    /// Drop expired holders for `hash`. Returns the number removed.
331    /// Called by [`Self::providers_at`] on the read path so a stale
332    /// holder never appears in a `FindValueResponse` even between GC
333    /// ticks.
334    fn scrub_hash_expired(&mut self, hash: &Hash, now_us: u64) -> usize {
335        // Collect the expired holders under an immutable borrow, then
336        // drop each through `remove_entry` so the tri-index update (and
337        // the empty-bucket cleanup) stays in one place.
338        let Some(entries) = self.by_hash.get(hash) else {
339            return 0;
340        };
341        let expired: Vec<NodeId> = entries
342            .iter()
343            .filter(|e| e.expiry_us <= now_us)
344            .map(|e| e.holder)
345            .collect();
346        for holder in &expired {
347            self.remove_entry(*holder, *hash);
348        }
349        expired.len()
350    }
351
352    /// Evict the globally-oldest record from every index.
353    fn evict_globally_oldest(&mut self) {
354        let Some(&(_, _, hash, holder)) = self.global_lru.iter().next() else {
355            return;
356        };
357        self.remove_entry(holder, hash);
358    }
359
360    /// Atomically insert `(holder, hash)` across all three indexes with
361    /// the given receive timestamp + sequence: the `by_hash` provider
362    /// list, the `global_lru` ordering, and the per-publisher count.
363    /// The caller has already verified (or, on the refresh path, ensured
364    /// by construction) that the publisher is below quota and that any
365    /// eviction owed for this insert has happened. Touching one index
366    /// without the others is the tri-index bug class this method exists to
367    /// make impossible — see [`Self::remove_entry`].
368    fn add_entry(
369        &mut self,
370        holder: NodeId,
371        hash: Hash,
372        coverage: Coverage,
373        receive_us: u64,
374        sequence: u64,
375    ) {
376        let expiry_us = receive_us.saturating_add(self.cfg.ttl_us);
377        self.by_hash.entry(hash).or_default().push(HolderEntry {
378            holder,
379            receive_us,
380            sequence,
381            expiry_us,
382            coverage,
383        });
384        self.global_lru.insert((receive_us, sequence, hash, holder));
385        *self.by_publisher_count.entry(holder).or_insert(0) += 1;
386    }
387
388    /// Atomically remove `(holder, hash)` from all three indexes. Returns
389    /// the removed [`HolderEntry`], or `None` if no such record exists.
390    /// `(holder, hash)` is unique within a hash's provider list —
391    /// [`Self::insert_at`] removes any existing `(holder, hash)` before it
392    /// adds, so a holder never accumulates two records for one hash — so
393    /// removal by holder is unambiguous.
394    fn remove_entry(&mut self, holder: NodeId, hash: Hash) -> Option<HolderEntry> {
395        let entries = self.by_hash.get_mut(&hash)?;
396        let idx = entries.iter().position(|e| e.holder == holder)?;
397        let Some(removed) = entries.get(idx).cloned() else {
398            // `position` just yielded `idx`, so `get(idx)` is `Some`
399            // except under heap corruption. The two legitimate
400            // "not a record" misses (`?` above) returned already, so a
401            // `None` here is never a normal miss — surface it loudly.
402            // Surface it at its true source so all
403            // callers (refresh, evictions, gc, scrub) inherit the error signal. The
404            // `get(idx)` keeps clippy's anti-panic policy off bare `[idx]`.
405            tracing::error!(
406                ?holder,
407                ?hash,
408                idx,
409                "RecordStore::remove_entry: position->Some but get->None; tri-index corruption"
410            );
411            return None;
412        };
413        entries.swap_remove(idx);
414        if entries.is_empty() {
415            self.by_hash.remove(&hash);
416        }
417        // Source every entry-derived key field from `removed` (it equals
418        // `holder` by construction — `position` matched on `e.holder ==
419        // holder` — but keeping them uniform avoids a future mismatch if
420        // the lookup predicate ever changes).
421        self.global_lru
422            .remove(&(removed.receive_us, removed.sequence, hash, removed.holder));
423        Self::decrement_publisher_count(&mut self.by_publisher_count, &removed.holder);
424        Some(removed)
425    }
426
427    /// Decrement a publisher's record count; remove the map entry when
428    /// the count drops to zero so `publisher_record_count` stays a
429    /// truthful "0 means unknown" view.
430    fn decrement_publisher_count(counts: &mut HashMap<NodeId, usize>, holder: &NodeId) {
431        if let Some(c) = counts.get_mut(holder) {
432            *c = c.saturating_sub(1);
433            if *c == 0 {
434                counts.remove(holder);
435            }
436        }
437    }
438}
439
440#[cfg(test)]
441#[allow(
442    clippy::unwrap_used,
443    clippy::expect_used,
444    clippy::panic,
445    clippy::indexing_slicing,
446    clippy::cast_possible_truncation
447)]
448mod tests;