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;