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;