Skip to main content

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;