decdn_node/dht/negative_cache.rs
1//! Requester-side negative probe cache (ADR 001 §Probe cache).
2//!
3//! Suppresses repeated `cdn/probe/v1` requests to a `(NodeId, hash)`
4//! pair that already returned `has_blob: false` within the TTL window.
5//! Per ADR 001 § Probe cache the cache:
6//!
7//! - is keyed `(NodeId, hash)`;
8//! - holds at most 1024 entries with LRU eviction;
9//! - retains entries for 5 minutes (TTL anchored at insertion);
10//! - does NOT retain the probe response signature — purely a request-
11//! suppression structure, not slashing evidence.
12//!
13//! The DHT iterative lookup ([`super::lookup`]) consumes
14//! [`NegativeProbeCache::contains_active`] as the third ADR 022
15//! §Lookup-integrity filter on `FindValueResponse.providers`.
16//! [`NegativeProbeCache::record_failure`] is the producer-side hook
17//! for the outbound `cdn/probe/v1` client.
18
19use std::sync::Mutex;
20use std::time::{Duration, Instant};
21
22use indexmap::IndexMap;
23use tracing::warn;
24
25use crate::dht::routing::NodeId;
26
27pub use crate::dht::records::Hash;
28
29/// ADR 001 § Probe cache: negative cache TTL is 5 minutes — longer than the
30/// positive probe cache (15s) because false-STORE results are less
31/// time-sensitive, and shorter than the DHT record TTL (1h) so a
32/// publisher that genuinely acquires the blob during the negative
33/// window can re-establish reachability after one cache lifetime.
34const DEFAULT_TTL: Duration = Duration::from_mins(5);
35
36/// ADR 001 § Probe cache: max 1024 entries.
37const DEFAULT_CAPACITY: usize = 1024;
38
39type Key = (NodeId, Hash);
40
41#[derive(Debug)]
42struct Inner {
43 /// Key → expiry deadline. `IndexMap` collapses what would
44 /// otherwise be a `HashMap` + side `VecDeque` (kept in lockstep
45 /// to track LRU order) into a single store: insertion order is
46 /// the LRU ordering, with index 0 = most-recently-used and
47 /// `len()-1` = least-recently-used. Bumping a hit means
48 /// `shift_insert(0, …)`; eviction means `pop` from the back.
49 entries: IndexMap<Key, Instant>,
50 /// Hard cap on live entries. Clamped to ≥ 1 in the constructor.
51 cap: usize,
52 /// TTL applied to each entry on insert. Anchored at insertion
53 /// (NOT refreshed on read) — see [`NegativeProbeCache::contains_active`].
54 ttl: Duration,
55}
56
57/// Bounded LRU cache of `(NodeId, hash)` pairs returned
58/// `has_blob: false` within the TTL window.
59#[derive(Debug)]
60pub struct NegativeProbeCache {
61 inner: Mutex<Inner>,
62}
63
64impl Default for NegativeProbeCache {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl NegativeProbeCache {
71 /// Build a cache with the ADR 001 § Probe cache defaults (1024 entries, 5-
72 /// minute TTL).
73 #[must_use]
74 pub fn new() -> Self {
75 Self::with_capacity_and_ttl(DEFAULT_CAPACITY, DEFAULT_TTL)
76 }
77
78 #[cfg(test)]
79 #[must_use]
80 fn with_capacity(cap: usize) -> Self {
81 Self::with_capacity_and_ttl(cap, DEFAULT_TTL)
82 }
83
84 /// Build a cache with an explicit capacity and TTL.
85 ///
86 /// Production code uses [`Self::new`] (the ADR 001 § Probe cache
87 /// 1024-entry, 5-minute defaults); this constructor is the test /
88 /// tuning seam. The TTL is anchored on [`std::time::Instant`], so
89 /// `tokio::time` pause / advance has no effect on it, and a short
90 /// injected TTL is how a test exercises expiry without a
91 /// multi-minute wall-clock wait. Two kinds of test want that:
92 ///
93 /// - One whose subject is TTL SEMANTICS — that expiry sweeps on
94 /// read, that a read-hit does not re-stamp the window, that a
95 /// re-record does. This module's own unit tests are those, and
96 /// their wall-clock sleeps are the measurement, not overhead.
97 /// - One whose subject is the CACHE-WIDE TTL as a quantity.
98 /// `node_origin`'s refusal suite is that case: it shrinks the
99 /// cache TTL below `node_origin::REFUSAL_SUPPRESSION_TTL` to
100 /// invert the production order, which is the only arrangement in
101 /// which the two suppression arms have visibly different
102 /// lifetimes.
103 ///
104 /// A test that needs one ENTRY expired as a precondition — rather
105 /// than as its subject — wants neither. Entries hold an absolute
106 /// expiry, so [`Self::record_failure_with_ttl`] with
107 /// [`Duration::ZERO`] stamps an already-elapsed expiry on a single
108 /// key, [`Self::contains_active`] sweeps it on the next read, and
109 /// the rest of the cache keeps production behaviour.
110 ///
111 /// `cap` is clamped to ≥ 1.
112 #[must_use]
113 pub fn with_capacity_and_ttl(cap: usize, ttl: Duration) -> Self {
114 let cap = cap.max(1);
115 Self {
116 inner: Mutex::new(Inner {
117 entries: IndexMap::with_capacity(cap),
118 cap,
119 ttl,
120 }),
121 }
122 }
123
124 /// Filter 3 (ADR 022 § Lookup integrity): returns `true` iff
125 /// `(node_id, hash)` is in the cache and its TTL hasn't elapsed.
126 /// A live hit bumps the entry to the front of the LRU ordering
127 /// (index 0) **without refreshing the entry's expiry** — TTL is
128 /// anchored at insertion per ADR 001 § Probe cache, not at read. An
129 /// expired entry is evicted before returning `false`.
130 #[must_use]
131 pub fn contains_active(&self, node_id: &NodeId, hash: &Hash) -> bool {
132 let key = (*node_id, *hash);
133 let now = Instant::now();
134 let mut guard = self.lock();
135 let Some(expiry) = guard.entries.get(&key).copied() else {
136 return false;
137 };
138 if expiry <= now {
139 guard.entries.shift_remove(&key);
140 return false;
141 }
142 // Bump to front of LRU, preserving the original expiry.
143 // `shift_insert` on an existing key moves it to the given
144 // index — no explicit remove needed.
145 guard.entries.shift_insert(0, key, expiry);
146 true
147 }
148
149 /// Insert / refresh `(node_id, hash)` with `now + TTL` expiry,
150 /// moving the entry to the front of the LRU ordering. Evicts the
151 /// least-recently-used entry on cap overflow. Producer-side hook
152 /// for the outbound probe client; the lookup module never calls
153 /// this directly.
154 ///
155 /// Uses the cache-wide TTL, which suits an AUTHORITATIVE negative:
156 /// a probe's `has_blob: false` is the peer having just looked. For
157 /// a weaker signal, pass its own TTL — see
158 /// [`Self::record_failure_with_ttl`].
159 pub fn record_failure(&self, node_id: NodeId, hash: Hash) {
160 let ttl = self.lock().ttl;
161 self.record_failure_with_ttl(node_id, hash, ttl);
162 }
163
164 /// [`Self::record_failure`] with an explicit TTL, for a negative
165 /// that is weaker than a probe's.
166 ///
167 /// Entries store an absolute expiry, so mixing TTLs is free: a
168 /// short-lived entry simply falls out sooner, and
169 /// [`Self::contains_active`] cannot tell the two apart (nor does it
170 /// need to). The caller owns the judgement of how much its evidence
171 /// is worth — see `node_origin::REFUSAL_SUPPRESSION_TTL`, which is
172 /// far shorter because a delivery refusal, unlike a probe answer,
173 /// may not be about the peer at all.
174 pub fn record_failure_with_ttl(&self, node_id: NodeId, hash: Hash, ttl: Duration) {
175 let key = (node_id, hash);
176 let mut guard = self.lock();
177 let expiry = Instant::now() + ttl;
178 // `shift_insert` moves an existing key to the new index and
179 // updates the value (returning the old) — which is exactly
180 // the MRU bump we want on the refresh path.
181 guard.entries.shift_insert(0, key, expiry);
182 if guard.entries.len() > guard.cap {
183 // `pop` removes the last entry — the LRU back.
184 guard.entries.pop();
185 }
186 }
187
188 /// Current entry count. Includes expired entries that haven't
189 /// been swept yet — call [`Self::contains_active`] first if a
190 /// precise live count is needed.
191 #[must_use]
192 pub fn len(&self) -> usize {
193 self.lock().entries.len()
194 }
195
196 /// Whether the cache holds zero entries (including stale).
197 #[must_use]
198 pub fn is_empty(&self) -> bool {
199 self.lock().entries.is_empty()
200 }
201
202 /// Poison-tolerant lock acquisition. Matches the in-repo pattern
203 /// already established for the chain-backed staker set's
204 /// `RwLock` and `decdn-cache`'s GC sweeps: a poisoned mutex
205 /// means *something panicked while holding the lock*, but our
206 /// writers never panic, so the inner state is structurally
207 /// sound and we recover rather than propagating.
208 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
209 match self.inner.lock() {
210 Ok(g) => g,
211 Err(poisoned) => {
212 warn!("NegativeProbeCache mutex poisoned; recovering inner state");
213 poisoned.into_inner()
214 }
215 }
216 }
217}
218
219#[cfg(test)]
220#[allow(
221 clippy::unwrap_used,
222 clippy::expect_used,
223 clippy::panic,
224 clippy::indexing_slicing
225)]
226mod tests;