reddb_server/storage/cache/extended_ttl.rs
1//! Extended TTL Policy & Effective Expiry
2//!
3//! Deep module that layers three additional expiry behaviours on top of the
4//! base "hard expiry" already computed by [`crate::storage::cache::blob::BlobCachePolicy`]:
5//!
6//! 1. **Idle TTL (sliding expiry):** an entry is killed if it has not been
7//! accessed within `idle_ttl_ms`, even if its hard expiry is in the future.
8//! 2. **Stale-While-Revalidate (SWR) window:** after the hard expiry passes,
9//! the entry can still be served as `Stale` for `stale_serve_ms`. Past that
10//! cumulative point, it is `Expired`.
11//! 3. **Jitter at insert time:** a deterministic, seed-driven offset of
12//! `[0, jitter_pct]` percent of the base TTL. Used to avoid synchronized
13//! cache stampedes when many entries are written in lockstep.
14//!
15//! ## Design Rules
16//!
17//! - **Hard expiry always wins.** No idle/stale config can resurrect an entry
18//! past `hard_expires_at + stale_serve_ms`.
19//! - **Pure functions only.** No clocks, no allocators, no global state. The
20//! caller supplies `now`, `last_access`, and pre-computed hard expiry.
21//! - **Stdlib-only.** No `rand`, no `chrono`, no `serde`. Jitter uses a small
22//! LCG seeded by the caller.
23//!
24//! This module is **additive**: integration with `BlobCachePolicy::ttl_ms`
25//! and `BlobCache::get` is a sequential follow-up. See module-level TODO
26//! comments in `mod.rs` (deferred).
27
28/// Extended TTL configuration that augments a base [`BlobCachePolicy`].
29///
30/// Construct via field literal or [`ExtendedTtlPolicy::off`]. All three knobs
31/// are independently optional / zero-able; [`Self::is_active`] reports whether
32/// any of them affects expiry decisions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct ExtendedTtlPolicy {
35 /// Sliding expiry: if the entry has not been accessed within this many
36 /// milliseconds, it is considered expired even if hard TTL has not passed.
37 /// `None` disables idle expiry.
38 pub idle_ttl_ms: Option<u64>,
39
40 /// Stale-while-revalidate window in milliseconds. After the hard expiry,
41 /// the entry can still be served as [`ExpiryDecision::Stale`] for this
42 /// many milliseconds before becoming [`ExpiryDecision::Expired`].
43 /// `None` disables the SWR window (hard expiry → immediate Expired).
44 pub stale_serve_ms: Option<u64>,
45
46 /// Jitter percentage applied at insert time, clamped to `0..=100`.
47 /// `0` disables jitter; values above `100` are treated as `100`.
48 pub jitter_pct: u8,
49}
50
51impl ExtendedTtlPolicy {
52 /// Returns the no-op extended policy: no idle, no SWR, no jitter.
53 /// In this state, `EffectiveExpiry::compute` reduces to a pure
54 /// hard-expiry check.
55 pub fn off() -> Self {
56 Self {
57 idle_ttl_ms: None,
58 stale_serve_ms: None,
59 jitter_pct: 0,
60 }
61 }
62
63 /// Reports whether this policy alters expiry behaviour relative to
64 /// raw hard-expiry semantics.
65 pub fn is_active(&self) -> bool {
66 self.idle_ttl_ms.is_some() || self.stale_serve_ms.is_some() || self.jitter_pct > 0
67 }
68}
69
70impl Default for ExtendedTtlPolicy {
71 fn default() -> Self {
72 Self::off()
73 }
74}
75
76/// Outcome of an [`EffectiveExpiry::compute`] call.
77///
78/// Hard expiry always wins: once the cumulative `hard + stale_serve` window
79/// is exhausted, no input can produce anything other than [`Self::Expired`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ExpiryDecision {
82 /// Entry is fully valid and should be served normally.
83 Fresh,
84
85 /// Hard expiry has passed but the entry is within its SWR window.
86 /// Caller may serve the cached value while triggering an async refresh.
87 Stale {
88 /// Milliseconds remaining in the stale window. When this reaches
89 /// zero on the next tick, the decision flips to `Expired`.
90 window_remaining_ms: u64,
91 },
92
93 /// Entry must not be served. Either:
94 /// - hard expiry + stale window is exhausted, or
95 /// - idle TTL was exceeded since last access.
96 Expired,
97}
98
99/// Stateless calculator for effective expiry decisions.
100///
101/// All methods are pure: they take inputs by value/reference, do no I/O,
102/// and never read the system clock. The caller is responsible for supplying
103/// a monotonically-correct `now_unix_ms`.
104pub struct EffectiveExpiry;
105
106impl EffectiveExpiry {
107 /// Compute the effective expiry decision for a cache entry.
108 ///
109 /// # Arguments
110 ///
111 /// - `hard_expires_at_unix_ms` — pre-computed hard expiry from the base
112 /// `BlobCachePolicy`. `None` means "no hard expiry" (entry never
113 /// hard-expires; only idle TTL can kill it).
114 /// - `now_unix_ms` — current time, supplied by caller.
115 /// - `last_access_unix_ms` — wall-clock time of the most recent access.
116 /// - `extended` — extended policy knobs.
117 ///
118 /// # Decision Order
119 ///
120 /// 1. **Idle TTL** — if configured and `now - last_access > idle_ttl`,
121 /// return `Expired` regardless of hard expiry. Idle is checked first
122 /// because an idle-killed entry is dead even within its hard window.
123 /// 2. **No hard expiry** — if `hard_expires_at_unix_ms` is `None`, the
124 /// only remaining gate is idle (already passed) → `Fresh`.
125 /// 3. **Within hard expiry** — `now <= hard` → `Fresh`.
126 /// 4. **Within SWR window** — `now <= hard + stale_serve_ms` → `Stale`.
127 /// 5. **Otherwise** → `Expired`.
128 pub fn compute(
129 hard_expires_at_unix_ms: Option<u64>,
130 now_unix_ms: u64,
131 last_access_unix_ms: u64,
132 extended: &ExtendedTtlPolicy,
133 ) -> ExpiryDecision {
134 // 1. Idle TTL gate. Saturating_sub guards against clock skew where
135 // last_access could be slightly ahead of now (treat as zero idle).
136 if let Some(idle_ttl_ms) = extended.idle_ttl_ms {
137 let idle_ms = now_unix_ms.saturating_sub(last_access_unix_ms);
138 if idle_ms > idle_ttl_ms {
139 return ExpiryDecision::Expired;
140 }
141 }
142
143 // 2. No hard expiry → Fresh (idle already cleared).
144 let Some(hard) = hard_expires_at_unix_ms else {
145 return ExpiryDecision::Fresh;
146 };
147
148 // 3. Within hard expiry.
149 if now_unix_ms <= hard {
150 return ExpiryDecision::Fresh;
151 }
152
153 // 4. Past hard expiry: check SWR window.
154 let stale_window = extended.stale_serve_ms.unwrap_or(0);
155 if stale_window == 0 {
156 return ExpiryDecision::Expired;
157 }
158
159 // saturating_add prevents overflow when hard is near u64::MAX.
160 let stale_deadline = hard.saturating_add(stale_window);
161 if now_unix_ms <= stale_deadline {
162 // Subtraction is safe: now > hard and now <= hard + stale_window
163 // imply stale_deadline >= now.
164 ExpiryDecision::Stale {
165 window_remaining_ms: stale_deadline - now_unix_ms,
166 }
167 } else {
168 ExpiryDecision::Expired
169 }
170 }
171
172 /// Compute a jittered TTL given a base TTL, a jitter percentage, and a
173 /// deterministic seed.
174 ///
175 /// Returns `base_ttl_ms + base_ttl_ms * offset / 100` where `offset`
176 /// is in `[0, jitter_pct]` (clamped to `0..=100`). When `jitter_pct == 0`
177 /// the result is exactly `base_ttl_ms`.
178 ///
179 /// Uses a small LCG (Numerical Recipes constants) so callers can supply
180 /// any `u64` seed (entry hash, write timestamp, etc.) without needing
181 /// `rand`. Same seed + same inputs → same result, always.
182 ///
183 /// Saturates on overflow rather than panicking.
184 pub fn jittered_ttl_ms(base_ttl_ms: u64, jitter_pct: u8, seed: u64) -> u64 {
185 let pct = jitter_pct.min(100) as u64;
186 if pct == 0 || base_ttl_ms == 0 {
187 return base_ttl_ms;
188 }
189
190 // Numerical Recipes LCG: x_{n+1} = 1664525 * x_n + 1013904223 mod 2^64
191 // One step is sufficient for "spread", we don't need crypto quality.
192 let mixed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
193
194 // offset ∈ [0, pct] inclusive → pct+1 buckets.
195 let offset = mixed % (pct + 1);
196
197 // base + base * offset / 100, with saturation at every step.
198 let extra = base_ttl_ms.saturating_mul(offset) / 100;
199 base_ttl_ms.saturating_add(extra)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 // ------------------------------------------------------------------
208 // ExtendedTtlPolicy::off / is_active
209 // ------------------------------------------------------------------
210
211 #[test]
212 fn off_is_inactive() {
213 let p = ExtendedTtlPolicy::off();
214 assert_eq!(p.idle_ttl_ms, None);
215 assert_eq!(p.stale_serve_ms, None);
216 assert_eq!(p.jitter_pct, 0);
217 assert!(!p.is_active());
218 }
219
220 #[test]
221 fn default_matches_off() {
222 assert_eq!(ExtendedTtlPolicy::default(), ExtendedTtlPolicy::off());
223 }
224
225 #[test]
226 fn any_field_set_makes_active() {
227 assert!(ExtendedTtlPolicy {
228 idle_ttl_ms: Some(1),
229 stale_serve_ms: None,
230 jitter_pct: 0,
231 }
232 .is_active());
233 assert!(ExtendedTtlPolicy {
234 idle_ttl_ms: None,
235 stale_serve_ms: Some(1),
236 jitter_pct: 0,
237 }
238 .is_active());
239 assert!(ExtendedTtlPolicy {
240 idle_ttl_ms: None,
241 stale_serve_ms: None,
242 jitter_pct: 1,
243 }
244 .is_active());
245 }
246
247 // ------------------------------------------------------------------
248 // Hard expiry always wins
249 // ------------------------------------------------------------------
250
251 #[test]
252 fn hard_expiry_always_wins_proptest_style() {
253 // For any extended config, now > hard + stale ⇒ Expired.
254 // We sweep a representative grid — full proptest crate not available
255 // and the spec said "no external deps".
256 let hards = [10u64, 100, 1_000, 10_000, u64::MAX / 2];
257 let stales = [0u64, 1, 50, 1_000, 1_000_000];
258 let idles = [None, Some(1u64), Some(10_000), Some(u64::MAX)];
259 let jitters = [0u8, 25, 50, 100, 250];
260
261 for &hard in &hards {
262 for &stale in &stales {
263 for &idle in &idles {
264 for &jitter in &jitters {
265 let ext = ExtendedTtlPolicy {
266 idle_ttl_ms: idle,
267 stale_serve_ms: Some(stale),
268 jitter_pct: jitter,
269 };
270 let now = hard.saturating_add(stale).saturating_add(1);
271 // last_access = now keeps idle from firing accidentally.
272 let decision = EffectiveExpiry::compute(Some(hard), now, now, &ext);
273 assert_eq!(
274 decision,
275 ExpiryDecision::Expired,
276 "hard={hard} stale={stale} idle={idle:?} jitter={jitter} now={now}",
277 );
278 }
279 }
280 }
281 }
282 }
283
284 // ------------------------------------------------------------------
285 // Idle TTL behaviour
286 // ------------------------------------------------------------------
287
288 #[test]
289 fn idle_ttl_kills_entry() {
290 let ext = ExtendedTtlPolicy {
291 idle_ttl_ms: Some(100),
292 stale_serve_ms: None,
293 jitter_pct: 0,
294 };
295 // last_access=0, now=101 → idle = 101 > 100 → Expired
296 let d = EffectiveExpiry::compute(Some(10_000), 101, 0, &ext);
297 assert_eq!(d, ExpiryDecision::Expired);
298 }
299
300 #[test]
301 fn idle_ttl_resets_on_access() {
302 let ext = ExtendedTtlPolicy {
303 idle_ttl_ms: Some(100),
304 stale_serve_ms: None,
305 jitter_pct: 0,
306 };
307 // last_access = now - 50, idle = 50 ≤ 100, hard far away → Fresh
308 let d = EffectiveExpiry::compute(Some(10_000), 1_000, 950, &ext);
309 assert_eq!(d, ExpiryDecision::Fresh);
310 }
311
312 #[test]
313 fn idle_ttl_at_boundary_is_fresh() {
314 // Spec: "now - last_access > idle_ttl_ms" — strictly greater.
315 // Equal means still alive.
316 let ext = ExtendedTtlPolicy {
317 idle_ttl_ms: Some(100),
318 stale_serve_ms: None,
319 jitter_pct: 0,
320 };
321 let d = EffectiveExpiry::compute(Some(10_000), 100, 0, &ext);
322 assert_eq!(d, ExpiryDecision::Fresh);
323 }
324
325 #[test]
326 fn idle_ttl_handles_clock_skew() {
327 // last_access slightly ahead of now — saturating_sub → idle = 0.
328 let ext = ExtendedTtlPolicy {
329 idle_ttl_ms: Some(100),
330 stale_serve_ms: None,
331 jitter_pct: 0,
332 };
333 let d = EffectiveExpiry::compute(Some(10_000), 500, 510, &ext);
334 assert_eq!(d, ExpiryDecision::Fresh);
335 }
336
337 // ------------------------------------------------------------------
338 // Stale window behaviour
339 // ------------------------------------------------------------------
340
341 #[test]
342 fn stale_window_fires_after_hard() {
343 // hard=100, stale=50, now=120 → Stale { window_remaining_ms: 30 }
344 let ext = ExtendedTtlPolicy {
345 idle_ttl_ms: None,
346 stale_serve_ms: Some(50),
347 jitter_pct: 0,
348 };
349 let d = EffectiveExpiry::compute(Some(100), 120, 120, &ext);
350 assert_eq!(
351 d,
352 ExpiryDecision::Stale {
353 window_remaining_ms: 30
354 }
355 );
356 }
357
358 #[test]
359 fn stale_window_at_exact_boundary() {
360 // now = hard + stale → still Stale with 0 ms remaining
361 let ext = ExtendedTtlPolicy {
362 idle_ttl_ms: None,
363 stale_serve_ms: Some(50),
364 jitter_pct: 0,
365 };
366 let d = EffectiveExpiry::compute(Some(100), 150, 150, &ext);
367 assert_eq!(
368 d,
369 ExpiryDecision::Stale {
370 window_remaining_ms: 0
371 }
372 );
373 }
374
375 #[test]
376 fn stale_window_expires() {
377 // hard=100, stale=50, now=151 → Expired
378 let ext = ExtendedTtlPolicy {
379 idle_ttl_ms: None,
380 stale_serve_ms: Some(50),
381 jitter_pct: 0,
382 };
383 let d = EffectiveExpiry::compute(Some(100), 151, 151, &ext);
384 assert_eq!(d, ExpiryDecision::Expired);
385 }
386
387 #[test]
388 fn no_stale_config_immediate_expired() {
389 // hard=100, now=101, no stale window → Expired
390 let ext = ExtendedTtlPolicy {
391 idle_ttl_ms: None,
392 stale_serve_ms: None,
393 jitter_pct: 0,
394 };
395 let d = EffectiveExpiry::compute(Some(100), 101, 101, &ext);
396 assert_eq!(d, ExpiryDecision::Expired);
397 }
398
399 #[test]
400 fn stale_zero_acts_like_no_stale() {
401 let ext = ExtendedTtlPolicy {
402 idle_ttl_ms: None,
403 stale_serve_ms: Some(0),
404 jitter_pct: 0,
405 };
406 let d = EffectiveExpiry::compute(Some(100), 101, 101, &ext);
407 assert_eq!(d, ExpiryDecision::Expired);
408 }
409
410 #[test]
411 fn within_hard_is_fresh_even_with_stale_configured() {
412 let ext = ExtendedTtlPolicy {
413 idle_ttl_ms: None,
414 stale_serve_ms: Some(1_000),
415 jitter_pct: 0,
416 };
417 let d = EffectiveExpiry::compute(Some(100), 50, 50, &ext);
418 assert_eq!(d, ExpiryDecision::Fresh);
419 }
420
421 #[test]
422 fn hard_at_exact_boundary_is_fresh() {
423 // Spec: "now <= hard" → Fresh. So now == hard is still Fresh.
424 let ext = ExtendedTtlPolicy::off();
425 let d = EffectiveExpiry::compute(Some(100), 100, 100, &ext);
426 assert_eq!(d, ExpiryDecision::Fresh);
427 }
428
429 #[test]
430 fn no_hard_expiry_is_fresh() {
431 let ext = ExtendedTtlPolicy::off();
432 let d = EffectiveExpiry::compute(None, u64::MAX, 0, &ext);
433 assert_eq!(d, ExpiryDecision::Fresh);
434 }
435
436 #[test]
437 fn no_hard_but_idle_still_kills() {
438 let ext = ExtendedTtlPolicy {
439 idle_ttl_ms: Some(50),
440 stale_serve_ms: None,
441 jitter_pct: 0,
442 };
443 let d = EffectiveExpiry::compute(None, 100, 0, &ext);
444 assert_eq!(d, ExpiryDecision::Expired);
445 }
446
447 // ------------------------------------------------------------------
448 // off() interaction with compute
449 // ------------------------------------------------------------------
450
451 #[test]
452 fn off_compute_never_returns_stale() {
453 let ext = ExtendedTtlPolicy::off();
454 // Sweep a grid — every result must be Fresh or Expired, never Stale.
455 for &hard in &[0u64, 1, 100, 10_000, u64::MAX] {
456 for &now in &[0u64, 1, 99, 100, 101, 10_001, u64::MAX] {
457 let d = EffectiveExpiry::compute(Some(hard), now, now, &ext);
458 assert!(
459 matches!(d, ExpiryDecision::Fresh | ExpiryDecision::Expired),
460 "off() must not produce Stale: hard={hard} now={now} got {d:?}",
461 );
462 }
463 }
464 }
465
466 // ------------------------------------------------------------------
467 // Jitter
468 // ------------------------------------------------------------------
469
470 #[test]
471 fn jitter_zero_is_identity() {
472 for base in [0u64, 1, 100, 1_000, 10_000_000] {
473 for seed in [0u64, 1, 42, u64::MAX] {
474 assert_eq!(
475 EffectiveExpiry::jittered_ttl_ms(base, 0, seed),
476 base,
477 "base={base} seed={seed}",
478 );
479 }
480 }
481 }
482
483 #[test]
484 fn jitter_zero_base_is_zero() {
485 // Edge case: 0 base TTL stays 0 regardless of jitter.
486 for pct in [0u8, 25, 100] {
487 assert_eq!(EffectiveExpiry::jittered_ttl_ms(0, pct, 12345), 0);
488 }
489 }
490
491 #[test]
492 fn jitter_bound_1000_calls() {
493 // 1000 calls with base=1000, pct=20 → all in [1000, 1200].
494 let base = 1_000u64;
495 let pct = 20u8;
496 for seed in 0u64..1_000 {
497 let v = EffectiveExpiry::jittered_ttl_ms(base, pct, seed);
498 assert!(
499 (1_000..=1_200).contains(&v),
500 "seed={seed} v={v} out of [1000, 1200]",
501 );
502 }
503 }
504
505 #[test]
506 fn jitter_deterministic() {
507 let base = 5_000u64;
508 let pct = 50u8;
509 for seed in [0u64, 1, 42, 999, u64::MAX, 0xDEAD_BEEF] {
510 let a = EffectiveExpiry::jittered_ttl_ms(base, pct, seed);
511 let b = EffectiveExpiry::jittered_ttl_ms(base, pct, seed);
512 assert_eq!(a, b, "seed={seed}: {a} != {b}");
513 }
514 }
515
516 #[test]
517 fn jitter_pct_clamps_above_100() {
518 // pct > 100 should be treated as 100 → max output base + base = 2*base.
519 let base = 1_000u64;
520 for seed in 0u64..200 {
521 let v = EffectiveExpiry::jittered_ttl_ms(base, 250, seed);
522 assert!(
523 (1_000..=2_000).contains(&v),
524 "seed={seed} v={v} out of [1000, 2000] for clamped pct",
525 );
526 }
527 }
528
529 #[test]
530 fn jitter_distribution_covers_range() {
531 // Sanity: with pct=100 and 10k seeds, we should hit both low and
532 // high ends. Not a statistical test — just a smoke check that the
533 // LCG isn't degenerate.
534 let base = 1_000u64;
535 let mut min_seen = u64::MAX;
536 let mut max_seen = 0u64;
537 for seed in 0u64..10_000 {
538 let v = EffectiveExpiry::jittered_ttl_ms(base, 100, seed);
539 min_seen = min_seen.min(v);
540 max_seen = max_seen.max(v);
541 }
542 assert!(min_seen <= 1_100, "min_seen={min_seen} too high");
543 assert!(max_seen >= 1_900, "max_seen={max_seen} too low");
544 }
545
546 #[test]
547 fn jitter_saturates_on_overflow() {
548 // base near u64::MAX with pct=100 — must not panic.
549 let v = EffectiveExpiry::jittered_ttl_ms(u64::MAX, 100, 1);
550 assert_eq!(v, u64::MAX);
551 }
552
553 // ------------------------------------------------------------------
554 // Combinatorial "proptest-style" cross-check against reference impl
555 // ------------------------------------------------------------------
556
557 /// Reference implementation, written line-by-line from the spec.
558 /// Used to verify the optimized `compute` over a wide grid.
559 fn reference_decision(
560 hard: Option<u64>,
561 now: u64,
562 last_access: u64,
563 ext: &ExtendedTtlPolicy,
564 ) -> ExpiryDecision {
565 // Step 1: idle TTL.
566 if let Some(idle) = ext.idle_ttl_ms {
567 let elapsed = if now >= last_access {
568 now - last_access
569 } else {
570 0
571 };
572 if elapsed > idle {
573 return ExpiryDecision::Expired;
574 }
575 }
576 // Step 2: no hard → Fresh.
577 let h = match hard {
578 None => return ExpiryDecision::Fresh,
579 Some(v) => v,
580 };
581 // Step 3: within hard.
582 if now <= h {
583 return ExpiryDecision::Fresh;
584 }
585 // Step 4: stale window.
586 let stale = ext.stale_serve_ms.unwrap_or(0);
587 if stale == 0 {
588 return ExpiryDecision::Expired;
589 }
590 let deadline = h.saturating_add(stale);
591 if now <= deadline {
592 ExpiryDecision::Stale {
593 window_remaining_ms: deadline - now,
594 }
595 } else {
596 ExpiryDecision::Expired
597 }
598 }
599
600 #[test]
601 fn combinatorial_matches_reference() {
602 // Deterministic LCG-driven sweep — generates ~5000 tuples without
603 // pulling proptest. Each tuple is checked against the reference.
604 let mut state: u64 = 0x1234_5678_9ABC_DEF0;
605 for _ in 0..5_000 {
606 state = state
607 .wrapping_mul(6_364_136_223_846_793_005)
608 .wrapping_add(1);
609 let hard = if state & 1 == 0 {
610 None
611 } else {
612 Some((state >> 1) % 10_000)
613 };
614 state = state
615 .wrapping_mul(6_364_136_223_846_793_005)
616 .wrapping_add(1);
617 let now = state % 12_000;
618 state = state
619 .wrapping_mul(6_364_136_223_846_793_005)
620 .wrapping_add(1);
621 let last_access = state % 12_000;
622 state = state
623 .wrapping_mul(6_364_136_223_846_793_005)
624 .wrapping_add(1);
625 let idle = if state & 1 == 0 {
626 None
627 } else {
628 Some((state >> 1) % 5_000)
629 };
630 state = state
631 .wrapping_mul(6_364_136_223_846_793_005)
632 .wrapping_add(1);
633 let stale = if state & 1 == 0 {
634 None
635 } else {
636 Some((state >> 1) % 5_000)
637 };
638 state = state
639 .wrapping_mul(6_364_136_223_846_793_005)
640 .wrapping_add(1);
641 let jitter = (state % 256) as u8;
642
643 let ext = ExtendedTtlPolicy {
644 idle_ttl_ms: idle,
645 stale_serve_ms: stale,
646 jitter_pct: jitter,
647 };
648
649 let actual = EffectiveExpiry::compute(hard, now, last_access, &ext);
650 let expected = reference_decision(hard, now, last_access, &ext);
651 assert_eq!(
652 actual, expected,
653 "hard={hard:?} now={now} last={last_access} ext={ext:?}",
654 );
655 }
656 }
657}