Skip to main content

decdn_cache/
origin_probe.rs

1//! Live-origin probe memo (#1130 pt3): a bounded, TTL'd cache of
2//! `HEAD`/`HeadObject` existence answers so a per-probe origin existence check
3//! does not hammer the backend.
4//!
5//! `rescan_origins` (#1130) builds the zero-I/O `origin_held` index from
6//! `enumerate()` ∪ pins, which for http/s3 covers pinned hashes ONLY (neither
7//! backend lists). A non-pinned bucket object is therefore invisible to the
8//! probe. This memo backs the per-probe fallback that consults the origin
9//! directly (`CacheEngine::origin_probe_size`): it caches BOTH a positive answer
10//! (`Present(size)`) under a long positive TTL and a negative one (`Absent`)
11//! under a short negative TTL, so a flood of random-hash probes turns into at
12//! most one `HeadObject` per hash per TTL window rather than one per probe,
13//! while a stale `Absent` cannot hide newly-available own content for more
14//! than the short negative window.
15//!
16//! The memo is deliberately a plain data structure with an injected clock
17//! (`now: Instant`) so its expiry and capacity behaviour are unit-testable
18//! without sleeping or a background sweeper. Expiry is lazy (on lookup); the
19//! capacity bound is enforced on insert.
20
21use std::collections::HashMap;
22use std::time::{Duration, Instant};
23
24use crate::Hash;
25
26/// Default TTL for a memoised positive origin-probe answer
27/// (`cache.origin_probe_ttl_sec`). Canonical seconds value from
28/// `decdn-config-types`, wrapped as a `Duration`.
29pub const DEFAULT_ORIGIN_PROBE_TTL: Duration =
30    Duration::from_secs(decdn_config_types::DEFAULT_ORIGIN_PROBE_TTL_SEC);
31/// Default negative TTL (`cache.origin_probe_negative_ttl_sec`). Short on
32/// purpose: it bounds how long a stale `Absent` can hide newly-available own
33/// content. A random-hash flood never repeats a hash within any window, so a
34/// short negative TTL barely changes the flood cost while capping the
35/// fresh-content dark window.
36pub const DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL: Duration =
37    Duration::from_secs(decdn_config_types::DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC);
38/// Default fault TTL (`cache.origin_probe_fault_ttl_sec`). Longer than the
39/// negative TTL because a client retrying one hash against a failing origin is
40/// the common shape, and this memo is keyed per hash: it collapses the repeats
41/// of a hash, not the namespace — during an outage, N distinct hashes still
42/// cost N live `HEAD`s per window. Shorter than the positive TTL because a
43/// memoised fault costs client-visible availability until it expires, so a
44/// recovered origin must be re-probed promptly.
45pub const DEFAULT_ORIGIN_PROBE_FAULT_TTL: Duration =
46    Duration::from_secs(decdn_config_types::DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC);
47/// Default per-probe live-`HEAD` ceiling (`cache.origin_probe_timeout_ms`) — a
48/// slow origin must never stall the probe hot path.
49pub const DEFAULT_ORIGIN_PROBE_TIMEOUT: Duration =
50    Duration::from_millis(decdn_config_types::DEFAULT_ORIGIN_PROBE_TIMEOUT_MS);
51/// Default cap on distinct memoised hashes (`cache.origin_probe_memo_capacity`).
52/// Bounds memory under a random-hash probe flood.
53#[allow(clippy::cast_possible_truncation)] // 4096 fits usize on every supported target.
54pub const DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY: usize =
55    decdn_config_types::DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY as usize;
56
57/// A memoised existence answer for a hash against the configured origins.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Presence {
60    /// The origin holds the blob; carries the total byte size for the probe's
61    /// `total_bytes`.
62    Present(u64),
63    /// No configured origin holds the blob (a `HEAD` 404, an unknown size,
64    /// or no origin — an authoritative "don't advertise").
65    Absent,
66    /// The probe could not get an authoritative answer: a transport error or
67    /// a timeout (a `HEAD` overrun). Distinct from `Absent` on purpose — a
68    /// caller that would otherwise sign an authoritative `NotFound` must not
69    /// do so on a fault, and a fault is worth memoising under its own TTL so
70    /// a failing origin is not re-probed on every request for its namespace.
71    Fault,
72}
73
74impl Presence {
75    /// The probe answer this presence yields: `Some(size)` advertises
76    /// `has_blob: true`, `None` leaves it `false`.
77    pub const fn size(self) -> Option<u64> {
78        match self {
79            Presence::Present(size) => Some(size),
80            Presence::Absent | Presence::Fault => None,
81        }
82    }
83}
84
85/// Bounded, lazily-expiring memo of origin-probe answers.
86#[derive(Debug)]
87pub struct OriginProbeMemo {
88    entries: HashMap<Hash, Entry>,
89    positive_ttl: Duration,
90    negative_ttl: Duration,
91    fault_ttl: Duration,
92    timeout: Duration,
93    capacity: usize,
94}
95
96#[derive(Debug, Clone, Copy)]
97struct Entry {
98    presence: Presence,
99    /// Instant at which this entry stops being valid.
100    expires_at: Instant,
101}
102
103/// Live-origin probe policy: the three answer TTLs, the per-probe `HEAD`
104/// ceiling, and the memo's capacity, resolved from the `cache.origin_probe_*`
105/// knobs.
106///
107/// Passed as one value rather than five positional arguments because four of
108/// them are `Duration`: any transposition of the TTLs and the timeout would
109/// compile and pass every test, while quietly changing how long a fault or an
110/// absence stands.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct OriginProbePolicy {
113    /// TTL for a memoised `Present` answer (`cache.origin_probe_ttl_sec`).
114    pub positive_ttl: Duration,
115    /// TTL for a memoised `Absent` answer
116    /// (`cache.origin_probe_negative_ttl_sec`).
117    pub negative_ttl: Duration,
118    /// TTL for a memoised `Fault` answer
119    /// (`cache.origin_probe_fault_ttl_sec`).
120    pub fault_ttl: Duration,
121    /// Per-probe ceiling on the live `HEAD`/`HeadObject`
122    /// (`cache.origin_probe_timeout_ms`).
123    pub timeout: Duration,
124    /// Max distinct hashes held in the memo
125    /// (`cache.origin_probe_memo_capacity`).
126    pub capacity: usize,
127}
128
129impl Default for OriginProbePolicy {
130    fn default() -> Self {
131        Self {
132            positive_ttl: DEFAULT_ORIGIN_PROBE_TTL,
133            negative_ttl: DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL,
134            fault_ttl: DEFAULT_ORIGIN_PROBE_FAULT_TTL,
135            timeout: DEFAULT_ORIGIN_PROBE_TIMEOUT,
136            capacity: DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY,
137        }
138    }
139}
140
141impl OriginProbeMemo {
142    /// A memo under `policy`. A `capacity` of 0 is treated as 1 so the map can
143    /// always hold the entry it just resolved.
144    #[must_use]
145    pub fn new(policy: OriginProbePolicy) -> Self {
146        Self {
147            entries: HashMap::new(),
148            positive_ttl: policy.positive_ttl,
149            negative_ttl: policy.negative_ttl,
150            fault_ttl: policy.fault_ttl,
151            timeout: policy.timeout,
152            capacity: policy.capacity.max(1),
153        }
154    }
155
156    /// How long a memoised `Fault` answer stands before the origin is
157    /// re-probed.
158    #[must_use]
159    pub const fn fault_ttl(&self) -> Duration {
160        self.fault_ttl
161    }
162
163    /// The per-probe live-`HEAD` ceiling this memo was configured with.
164    pub const fn timeout(&self) -> Duration {
165        self.timeout
166    }
167
168    /// A live (non-expired) memoised answer for `hash`, or `None` on a miss.
169    /// Expired entries are swept in passing so a stale answer is never returned
170    /// and the map does not accrete dead entries on the read path.
171    pub fn get(&mut self, hash: Hash, now: Instant) -> Option<Presence> {
172        match self.entries.get(&hash) {
173            Some(entry) if entry.expires_at > now => Some(entry.presence),
174            Some(_) => {
175                self.entries.remove(&hash);
176                None
177            }
178            None => None,
179        }
180    }
181
182    /// Record `presence` for `hash`, valid for one TTL from `now`. Enforces the
183    /// capacity bound: expired entries are swept first, and if the map is still
184    /// at capacity one arbitrary live entry is dropped to make room, so the memo
185    /// can never exceed `capacity` distinct hashes regardless of probe volume.
186    pub fn insert(&mut self, hash: Hash, presence: Presence, now: Instant) {
187        let ttl = match presence {
188            Presence::Present(_) => self.positive_ttl,
189            Presence::Absent => self.negative_ttl,
190            Presence::Fault => self.fault_ttl,
191        };
192        let expires_at = now + ttl;
193        // Refreshing an existing key never grows the map.
194        if let std::collections::hash_map::Entry::Occupied(mut occupied) = self.entries.entry(hash)
195        {
196            occupied.insert(Entry {
197                presence,
198                expires_at,
199            });
200            return;
201        }
202        if self.entries.len() >= self.capacity {
203            self.sweep_expired(now);
204        }
205        if self.entries.len() >= self.capacity {
206            // Still full of live entries: evict one arbitrary key. HashMap
207            // iteration order is unspecified, which is an acceptable eviction
208            // policy for a best-effort existence cache (no LRU dependency).
209            if let Some(&victim) = self.entries.keys().next() {
210                self.entries.remove(&victim);
211            }
212        }
213        self.entries.insert(
214            hash,
215            Entry {
216                presence,
217                expires_at,
218            },
219        );
220    }
221
222    /// Drop every entry whose TTL has passed.
223    fn sweep_expired(&mut self, now: Instant) {
224        self.entries.retain(|_, e| e.expires_at > now);
225    }
226
227    /// Number of memoised entries (test/introspection aid).
228    #[must_use]
229    pub fn len(&self) -> usize {
230        self.entries.len()
231    }
232
233    /// Whether the memo holds no entries.
234    #[must_use]
235    pub fn is_empty(&self) -> bool {
236        self.entries.is_empty()
237    }
238}
239
240impl Default for OriginProbeMemo {
241    fn default() -> Self {
242        Self::new(OriginProbePolicy::default())
243    }
244}
245
246#[cfg(test)]
247mod tests;