decdn_node/dht/probe_cache.rs
1//! Requester-side POSITIVE probe cache (ADR 001 §Probe cache).
2//!
3//! The mirror of [`super::negative_cache`]: where that one remembers which
4//! `(NodeId, hash)` pairs answered `has_blob: false` (or refused a pull), this
5//! one remembers which nodes answered `has_blob: true` — so a second miss for the same hash inside
6//! the TTL skips the DHT lookup AND the probe fanout entirely and goes straight
7//! to selection. Per ADR 001 §Probe cache the cache:
8//!
9//! - is keyed by `hash`;
10//! - holds at most 1024 hashes with LRU eviction;
11//! - retains at most 10 providers per hash (top 10 by selection score);
12//! - retains entries for `PROBE_SLASH_WINDOW / 2` = 15s (ADR 005 §Derived
13//! constants), anchored at insertion;
14//! - stores the ADR triple `(NodeId, rate_per_mb, rtt)` plus the probe's
15//! UNSIGNED range-keyed `Coverage` (#1506) — never the signed `ProbeResponse`.
16//!
17//! That last point is #1165's "no evidence retention" requirement, and it is
18//! not a memory optimisation. A `ProbeResponse` carries `slash_sig`: a peer's
19//! signed `has_blob: true`. Paired with a stream response it is on-chain
20//! rate-manipulation evidence for `PROBE_SLASH_WINDOW`, and it can also
21//! corroborate a blacklist violation — which is bounded by `MAX_EVIDENCE_AGE_US`
22//! (5 days), not by the 30s window (ADR 014 §Evidence staleness). So the 15s TTL
23//! does not make retention harmless by expiry alone; the point is simply that a
24//! structure surviving one request to speed up the next has no business holding
25//! another node's slashable statements — retaining them turns an availability
26//! cache into an evidence locker.
27//!
28//! `Coverage` is exempt from that concern, not an exception to it: it is
29//! UNSIGNED (`ProbeResponseExt`, outside the `slash_sig` set), so it carries no
30//! author to slash and is not evidence of anything. It rides the entry so a
31//! cache-hit candidate can still be range-planned (`plan_covered_runs`) against
32//! its ≤15s-fresh coverage; without it a real partial holder would read as
33//! covering nothing on the hit path and be excluded from range assignment.
34//!
35//! `reputation` is likewise NOT stored, for a different reason: it is a local,
36//! live value that moves on every pull outcome. Freezing it for the TTL would let a
37//! node that just failed three pulls keep the rank it held before them.
38//! `node_origin::cached_candidates` recomputes reputation and region on read and
39//! re-runs `rank_candidates` — ADR 001's "goes straight to selection" means
40//! skipping discovery and probing, not skipping the selection algorithm.
41
42use std::sync::Mutex;
43use std::time::{Duration, Instant};
44
45use decdn_cache::PROBE_SLASH_WINDOW;
46use decdn_protocol::Coverage;
47use indexmap::IndexMap;
48use tracing::warn;
49
50use crate::dht::routing::NodeId;
51
52pub use crate::dht::records::Hash;
53
54/// ADR 005 §Derived constants: `probe_cache_ttl = PROBE_SLASH_WINDOW / 2` = 15s.
55///
56/// Derived rather than written as a literal `15s` because ADR 005 says so
57/// ("Implementations SHOULD define `PROBE_SLASH_WINDOW` as a named constant and
58/// compute the others from it"): a governance change to the slashing window must
59/// move this with it, and a literal would silently not move. The 15s ceiling is
60/// what keeps a stream opened from a cached entry inside the window during which
61/// a misbehaving provider is still slashable — and, per ADR 001, below
62/// `probe_hold_duration` ([`decdn_cache::PROBE_HOLD_DURATION`], 35s), so the
63/// provider's eviction hold still covers the blob when that stream opens. The
64/// second bound is what makes `EvictedSinceProbe` rare on the hit path.
65///
66/// Expressed through `as_secs` because `Duration: Div<u32>` is not `const` — the
67/// same reason [`decdn_cache::PROBE_HOLD_DURATION`] reaches for
68/// `saturating_add`. Integer division truncates sub-second remainders; harmless
69/// at the current even 30s, and the halving is a policy ratio, not an exact
70/// arithmetic requirement.
71const DEFAULT_TTL: Duration = Duration::from_secs(PROBE_SLASH_WINDOW.as_secs() / 2);
72
73/// ADR 001 §Probe cache: max 1024 entries.
74const DEFAULT_CAPACITY: usize = 1024;
75
76/// ADR 001 §Probe cache: "Each hash entry retains at most 10 responses (top 10
77/// by selection score)." With [`DEFAULT_CAPACITY`] this is what bounds the
78/// cache's memory at the ADR's stated ~1 MB (1024 × 10 × ~100 bytes).
79///
80/// Enforced by [`PositiveProbeCache::insert`] rather than by its caller, so the
81/// bound is an invariant of the TYPE: a cap a caller can forget is not a cap.
82const MAX_PROVIDERS_PER_HASH: usize = 10;
83
84/// One probed provider — ADR 001 §Probe cache's entry triple
85/// (`hash → Vec<(NodeId, rate_per_mb, rtt)>`) plus the probe's unsigned
86/// range-keyed [`Coverage`] (#1506), and nothing more.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProbedProvider {
89 /// The provider that answered `has_blob: true`.
90 pub node_id: NodeId,
91 /// Its quoted `ProbeResponse::rate_per_mb`.
92 pub rate_per_mb: u64,
93 /// The round-trip latency observed on that probe.
94 pub rtt_ms: u32,
95 /// The discovery blocks the provider reported holding on that probe
96 /// (`ProbeResponseExt.coverage`, #1506). UNSIGNED — outside the `slash_sig`
97 /// set — so retaining it is not the evidence retention the module doc
98 /// forbids; it carries no slashable author. A cache-hit candidate is
99 /// range-planned against this ≤15s-fresh value rather than reading as
100 /// covering nothing.
101 pub coverage: Coverage,
102 /// The blob size the provider reported on that probe
103 /// (`ProbeResponseExt.total_bytes`), when it knew it. UNSIGNED, like
104 /// `coverage`, so it carries no slashable author either: a sizing hint the
105 /// pull leg cuts its first upstream open from.
106 pub total_bytes_hint: Option<u64>,
107}
108
109// The guard against retaining slashable evidence is the exhaustive struct
110// literal at the single write site (no `..`, so any new field must be populated
111// there — sending the author back to the module doc). `ProbedProvider` does not
112// derive `Copy`: `Coverage` wraps a `Vec<u8>`, which cannot be `Copy`. A
113// footprint-ceiling `size_of` assert would only ever bound the fixed head, not
114// the heap the coverage bitmap owns, so this struct carries none. A
115// `Signature`/`ProbeResponse` field is still caught at the write site, which is
116// where the invariant actually lives.
117
118#[derive(Debug)]
119struct Entry {
120 /// Providers in write-time ranked order, best-first, truncated to
121 /// [`MAX_PROVIDERS_PER_HASH`].
122 providers: Vec<ProbedProvider>,
123 /// Absolute expiry, anchored at insert and never refreshed on read.
124 expiry: Instant,
125}
126
127#[derive(Debug)]
128struct Inner {
129 /// Hash → entry. `IndexMap` collapses what would otherwise be a
130 /// `HashMap` + side `VecDeque` (kept in lockstep to track LRU
131 /// order) into a single store: insertion order is the LRU ordering,
132 /// index 0 = most-recently-used and `len()-1` = least-recently-used.
133 /// Identical to [`super::negative_cache`] — deliberately, so the
134 /// two caches' eviction and expiry semantics cannot drift.
135 entries: IndexMap<Hash, Entry>,
136 /// Hard cap on live hashes. Clamped to ≥ 1 in the constructor.
137 cap: usize,
138 /// TTL applied on insert. Anchored at insertion, NOT refreshed on read.
139 ttl: Duration,
140}
141
142/// Bounded LRU cache of `hash → Vec<(NodeId, rate_per_mb, rtt)>`.
143#[derive(Debug)]
144pub struct PositiveProbeCache {
145 inner: Mutex<Inner>,
146}
147
148impl Default for PositiveProbeCache {
149 fn default() -> Self {
150 Self::new()
151 }
152}
153
154impl PositiveProbeCache {
155 /// Build a cache with the ADR 001 §Probe cache defaults (1024 hashes, 15s
156 /// TTL, ≤10 providers per hash).
157 #[must_use]
158 pub fn new() -> Self {
159 Self::with_capacity_and_ttl(DEFAULT_CAPACITY, DEFAULT_TTL)
160 }
161
162 #[cfg(test)]
163 #[must_use]
164 fn with_capacity(cap: usize) -> Self {
165 Self::with_capacity_and_ttl(cap, DEFAULT_TTL)
166 }
167
168 /// Build a cache with an explicit capacity and TTL.
169 ///
170 /// Production code uses [`Self::new`]; this is the test / tuning seam, for
171 /// the same reason
172 /// [`super::negative_cache::NegativeProbeCache::with_capacity_and_ttl`] is:
173 /// the TTL is anchored on [`Instant`], so `tokio::time` pause / advance has
174 /// no effect on it, and 15s of wall clock per assertion is not a test suite.
175 /// `cap` is clamped to ≥ 1.
176 ///
177 /// A `ttl` of [`Duration::ZERO`] disables the cache behaviourally: every
178 /// entry is already expired the instant it is read, so [`Self::get`] always
179 /// misses. That is the supported way to opt out of positive caching — there
180 /// is deliberately no `disabled()` constructor, because a constructor that
181 /// turns off an ADR-mandated behaviour is a thing production code will
182 /// eventually call.
183 #[must_use]
184 pub fn with_capacity_and_ttl(cap: usize, ttl: Duration) -> Self {
185 let cap = cap.max(1);
186 Self {
187 inner: Mutex::new(Inner {
188 entries: IndexMap::with_capacity(cap),
189 cap,
190 ttl,
191 }),
192 }
193 }
194
195 /// The cached providers for `hash`, best-first, iff an entry exists and its
196 /// TTL hasn't elapsed.
197 ///
198 /// A live hit bumps the entry to the front of the LRU ordering (index 0)
199 /// **without refreshing its expiry** — TTL is anchored at insertion per
200 /// ADR 001 §Probe cache. An expired entry is evicted before returning
201 /// `None`.
202 ///
203 /// Returns a clone rather than a guard-scoped borrow on purpose: the caller
204 /// rebuilds `Candidate`s from this, which means an `await` on `region_of(..)`
205 /// per provider, and holding a `std::sync::Mutex` guard across an await is
206 /// exactly the hazard `clippy::await_holding_lock` exists for. At ≤10 small
207 /// providers — each a triple plus a coverage bitmap sized to the blob's
208 /// block count — the clone is not worth arguing about.
209 #[must_use]
210 pub fn get(&self, hash: &Hash) -> Option<Vec<ProbedProvider>> {
211 let now = Instant::now();
212 let mut guard = self.lock();
213 // Remove first: an expired entry is then already evicted (below), and a
214 // live one is re-inserted at the front. This drops the redundant `get`
215 // that a check-then-remove-then-reinsert shape would cost, and keeps the
216 // hit path to two `IndexMap` ops (#1223 review).
217 let entry = guard.entries.shift_remove(hash)?;
218 if entry.expiry <= now {
219 return None;
220 }
221 // Bump to front of LRU, preserving the original expiry. Unlike
222 // `negative_cache`, whose `Instant` value is `Copy` and so can ride a
223 // single `shift_insert(0, key, expiry)`, the `Entry` here must be moved
224 // out and back.
225 let providers = entry.providers.clone();
226 guard.entries.shift_insert(0, *hash, entry);
227 Some(providers)
228 }
229
230 /// Insert / replace the entry for `hash` with `now + TTL` expiry, moving it
231 /// to the front of the LRU ordering. Evicts the least-recently-used hash on
232 /// cap overflow.
233 ///
234 /// `providers` MUST already be in selection order, best-first: this keeps the
235 /// first `MAX_PROVIDERS_PER_HASH`, which is ADR 001's "top 10 by selection
236 /// score" only if the caller ranked first. The cache cannot rank them itself
237 /// — the selection score needs `reputation`, which is precisely the field
238 /// this cache refuses to store.
239 ///
240 /// An empty `providers` is a no-op. An empty entry is strictly worse than no
241 /// entry: it would occupy an LRU slot, hit on every read, and yield nothing
242 /// — a cache of "we found nobody", which is the negative cache's job and not
243 /// on the negative cache's terms.
244 pub fn insert(&self, hash: Hash, mut providers: Vec<ProbedProvider>) {
245 if providers.is_empty() {
246 return;
247 }
248 providers.truncate(MAX_PROVIDERS_PER_HASH);
249 let mut guard = self.lock();
250 let expiry = Instant::now() + guard.ttl;
251 let entry = Entry { providers, expiry };
252 // `shift_insert` moves an existing key to the new index and replaces the
253 // value — the MRU bump we want on the refresh path.
254 guard.entries.shift_insert(0, hash, entry);
255 if guard.entries.len() > guard.cap {
256 // `pop` removes the last entry — the LRU back.
257 guard.entries.pop();
258 }
259 }
260
261 /// Drop the entry for `hash`, if any.
262 ///
263 /// ADR 001 §Probe cache: "if all fail, run a fresh DHT lookup + probe." A hit
264 /// whose every BUDGETED provider failed to deliver has been disproved by the
265 /// only evidence that outranks a probe — actual pulls — so it is removed
266 /// rather than left to keep hitting for the rest of its TTL. The caller may
267 /// not have tried the entry's whole list (the attempt budget can be smaller
268 /// than the list); the top-ranked members failing is disproof enough.
269 pub fn invalidate(&self, hash: &Hash) {
270 self.lock().entries.shift_remove(hash);
271 }
272
273 /// Current hash count. Includes expired entries that haven't been swept yet
274 /// — call [`Self::get`] first if a precise live count is needed.
275 #[must_use]
276 pub fn len(&self) -> usize {
277 self.lock().entries.len()
278 }
279
280 /// Whether the cache holds zero entries (including stale).
281 #[must_use]
282 pub fn is_empty(&self) -> bool {
283 self.lock().entries.is_empty()
284 }
285
286 /// Poison-tolerant lock acquisition — see
287 /// [`super::negative_cache::NegativeProbeCache`] for why we recover rather
288 /// than propagate.
289 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
290 match self.inner.lock() {
291 Ok(g) => g,
292 Err(poisoned) => {
293 warn!("PositiveProbeCache mutex poisoned; recovering inner state");
294 poisoned.into_inner()
295 }
296 }
297 }
298}
299
300#[cfg(test)]
301#[allow(
302 clippy::unwrap_used,
303 clippy::expect_used,
304 clippy::panic,
305 clippy::indexing_slicing
306)]
307mod tests;