Skip to main content

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}