Skip to main content

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;