Skip to main content

nntp_proxy/cache/
ttl.rs

1//! Tier-aware TTL calculations
2//!
3//! Higher tier backends get longer cache TTLs to reduce expensive backup server queries.
4//!
5//! ## Formula
6//! `effective_ttl = base_ttl * (2 ^ tier)`
7//!
8//! | Tier | Multiplier | Example (1h base) |
9//! |------|------------|-------------------|
10//! | 0    | 1x         | 1 hour            |
11//! | 1    | 2x         | 2 hours           |
12//! | 2    | 4x         | 4 hours           |
13//! | 7    | 128x       | 5.3 days          |
14//! | 10   | 1024x      | 42.7 days         |
15//!
16//! Tiers above 63 are capped (max safe bit shift for u64).
17
18/// Maximum tier for TTL calculation (prevents shift overflow)
19///
20/// Tiers above this are capped because `1u64 << 64` would panic.
21/// In practice, tier 63 gives a 2^63 multiplier, which for a 1 hour base TTL
22/// corresponds to an astronomically large duration (on the order of 10^15 years),
23/// i.e. effectively infinite for cache purposes.
24pub const MAX_TTL_TIER: u8 = 63;
25
26/// Backend cache tier used for tier-aware TTL calculations.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
28pub struct CacheTier(u8);
29
30impl CacheTier {
31    #[must_use]
32    pub const fn new(value: u8) -> Self {
33        Self(value)
34    }
35
36    #[must_use]
37    pub const fn get(self) -> u8 {
38        self.0
39    }
40}
41
42impl From<u8> for CacheTier {
43    fn from(value: u8) -> Self {
44        Self::new(value)
45    }
46}
47
48/// Millisecond Unix timestamp used by cache entries.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
50pub struct CacheTimestampMillis(u64);
51
52impl CacheTimestampMillis {
53    #[must_use]
54    pub const fn new(value: u64) -> Self {
55        Self(value)
56    }
57
58    #[must_use]
59    pub const fn get(self) -> u64 {
60        self.0
61    }
62
63    #[must_use]
64    pub fn now() -> Self {
65        Self(now_millis())
66    }
67}
68
69impl From<u64> for CacheTimestampMillis {
70    fn from(value: u64) -> Self {
71        Self::new(value)
72    }
73}
74
75/// Base cache TTL in milliseconds.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
77pub struct CacheTtlMillis(u64);
78
79impl CacheTtlMillis {
80    #[must_use]
81    pub const fn new(value: u64) -> Self {
82        Self(value)
83    }
84
85    #[must_use]
86    pub const fn get(self) -> u64 {
87        self.0
88    }
89
90    #[must_use]
91    pub fn from_duration(duration: std::time::Duration) -> Self {
92        Self(duration_millis_u64(duration))
93    }
94}
95
96impl From<u64> for CacheTtlMillis {
97    fn from(value: u64) -> Self {
98        Self::new(value)
99    }
100}
101
102/// Calculate effective TTL based on tier
103///
104/// Returns `base_ttl * (2 ^ min(tier, MAX_TTL_TIER))`
105///
106/// # Examples
107/// ```
108/// use nntp_proxy::cache::ttl::{CacheTier, CacheTtlMillis, effective_ttl};
109///
110/// assert_eq!(effective_ttl(CacheTtlMillis::new(1000), CacheTier::new(0)).get(), 1000);  // 1x
111/// assert_eq!(effective_ttl(CacheTtlMillis::new(1000), CacheTier::new(1)).get(), 2000);  // 2x
112/// assert_eq!(effective_ttl(CacheTtlMillis::new(1000), CacheTier::new(2)).get(), 4000);  // 4x
113/// assert_eq!(effective_ttl(CacheTtlMillis::new(1000), CacheTier::new(10)).get(), 1_024_000); // 1024x
114/// ```
115#[inline]
116#[must_use]
117pub const fn effective_ttl(base_ttl: CacheTtlMillis, tier: CacheTier) -> CacheTtlMillis {
118    let tier = tier.get();
119    let capped_tier = if tier > MAX_TTL_TIER {
120        MAX_TTL_TIER
121    } else {
122        tier
123    };
124    CacheTtlMillis::new(base_ttl.get().saturating_mul(1u64 << capped_tier))
125}
126
127/// Get current timestamp in milliseconds since Unix epoch
128#[inline]
129#[must_use]
130pub fn now_millis() -> u64 {
131    std::time::SystemTime::now()
132        .duration_since(std::time::UNIX_EPOCH)
133        .map_or(0, duration_millis_u64)
134}
135
136#[allow(clippy::cast_possible_truncation)] // `Duration::as_millis` is saturated back into our u64 TTL counter.
137fn duration_millis_u64(duration: std::time::Duration) -> u64 {
138    // TTLs and timestamps are stored as u64 millisecond counters. Saturating on
139    // overflow preserves ordering while avoiding a noisy error path here.
140    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
141}
142
143/// Check if an entry has expired based on tier-aware TTL
144///
145/// # Arguments
146/// * `inserted_at_millis` - When the entry was cached (milliseconds since epoch)
147/// * `base_ttl_millis` - Base TTL in milliseconds
148/// * `tier` - Backend tier (0 = primary, higher = backup)
149///
150/// # Examples
151/// ```
152/// use nntp_proxy::cache::ttl::{CacheTier, CacheTimestampMillis, CacheTtlMillis, is_expired, now_millis};
153///
154/// let now = now_millis();
155/// assert!(!is_expired(CacheTimestampMillis::new(now), CacheTtlMillis::new(1000), CacheTier::new(0))); // Just inserted, not expired
156///
157/// let old = now.saturating_sub(1500);
158/// assert!(is_expired(CacheTimestampMillis::new(old), CacheTtlMillis::new(1000), CacheTier::new(0))); // 1.5s ago with 1s TTL = expired
159/// assert!(!is_expired(CacheTimestampMillis::new(old), CacheTtlMillis::new(1000), CacheTier::new(1))); // 1.5s ago with 2s TTL = not expired
160/// ```
161#[inline]
162#[must_use]
163pub fn is_expired(
164    inserted_at: CacheTimestampMillis,
165    base_ttl: CacheTtlMillis,
166    tier: CacheTier,
167) -> bool {
168    let elapsed = now_millis().saturating_sub(inserted_at.get());
169    elapsed >= effective_ttl(base_ttl, tier).get()
170}
171
172/// TTL multiplier for a given tier (for display/logging)
173///
174/// # Examples
175/// ```
176/// use nntp_proxy::cache::ttl::{CacheTier, ttl_multiplier};
177///
178/// assert_eq!(ttl_multiplier(CacheTier::new(0)), 1);
179/// assert_eq!(ttl_multiplier(CacheTier::new(1)), 2);
180/// assert_eq!(ttl_multiplier(CacheTier::new(10)), 1024);
181/// assert_eq!(ttl_multiplier(CacheTier::new(63)), 1u64 << 63); // max
182/// ```
183#[inline]
184#[must_use]
185pub const fn ttl_multiplier(tier: CacheTier) -> u64 {
186    let tier = tier.get();
187    let capped = if tier > MAX_TTL_TIER {
188        MAX_TTL_TIER
189    } else {
190        tier
191    };
192    1u64 << capped
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    const fn ttl(value: u64) -> CacheTtlMillis {
200        CacheTtlMillis::new(value)
201    }
202
203    const fn timestamp(value: u64) -> CacheTimestampMillis {
204        CacheTimestampMillis::new(value)
205    }
206
207    const fn effective_ttl_ms(base_ttl_millis: u64, tier: CacheTier) -> u64 {
208        effective_ttl(ttl(base_ttl_millis), tier).get()
209    }
210
211    fn is_expired_ms(inserted_at_millis: u64, base_ttl_millis: u64, tier: CacheTier) -> bool {
212        is_expired(timestamp(inserted_at_millis), ttl(base_ttl_millis), tier)
213    }
214
215    // =========================================================================
216    // effective_ttl tests
217    // =========================================================================
218
219    #[test]
220    fn effective_ttl_tier_0_is_base() {
221        assert_eq!(effective_ttl_ms(1000, CacheTier::new(0)), 1000);
222        assert_eq!(effective_ttl_ms(0, CacheTier::new(0)), 0);
223        assert_eq!(effective_ttl_ms(1, CacheTier::new(0)), 1);
224    }
225
226    #[test]
227    fn effective_ttl_tier_1_is_2x() {
228        assert_eq!(effective_ttl_ms(1000, CacheTier::new(1)), 2000);
229        assert_eq!(effective_ttl_ms(500, CacheTier::new(1)), 1000);
230    }
231
232    #[test]
233    fn effective_ttl_tier_2_is_4x() {
234        assert_eq!(effective_ttl_ms(1000, CacheTier::new(2)), 4000);
235    }
236
237    #[test]
238    fn effective_ttl_tier_3_is_8x() {
239        assert_eq!(effective_ttl_ms(1000, CacheTier::new(3)), 8000);
240    }
241
242    #[test]
243    fn effective_ttl_tier_7_is_128x() {
244        assert_eq!(effective_ttl_ms(1000, CacheTier::new(7)), 128_000);
245    }
246
247    #[test]
248    fn effective_ttl_tier_10_is_1024x() {
249        assert_eq!(effective_ttl_ms(1000, CacheTier::new(10)), 1_024_000);
250    }
251
252    #[test]
253    fn effective_ttl_caps_at_tier_63() {
254        let tier_63_result = effective_ttl_ms(1, CacheTier::new(63));
255        assert_eq!(tier_63_result, 1u64 << 63);
256        // Tiers above 63 should give same result
257        assert_eq!(effective_ttl_ms(1, CacheTier::new(64)), tier_63_result);
258        assert_eq!(effective_ttl_ms(1, CacheTier::new(100)), tier_63_result);
259        assert_eq!(effective_ttl_ms(1, CacheTier::new(255)), tier_63_result);
260    }
261
262    #[test]
263    fn effective_ttl_saturates_on_overflow() {
264        // Very large base TTL with high tier shouldn't overflow
265        assert_eq!(effective_ttl_ms(u64::MAX, CacheTier::new(0)), u64::MAX);
266        assert_eq!(effective_ttl_ms(u64::MAX, CacheTier::new(1)), u64::MAX);
267        assert_eq!(effective_ttl_ms(u64::MAX, CacheTier::new(63)), u64::MAX);
268
269        // Large value that would overflow without saturation
270        // u64::MAX / 2 + 1 times 2 would overflow
271        assert_eq!(
272            effective_ttl_ms(u64::MAX / 2 + 1, CacheTier::new(1)),
273            u64::MAX
274        );
275    }
276
277    #[test]
278    fn effective_ttl_zero_base() {
279        // Zero TTL should stay zero regardless of tier
280        assert_eq!(effective_ttl_ms(0, CacheTier::new(0)), 0);
281        assert_eq!(effective_ttl_ms(0, CacheTier::new(1)), 0);
282        assert_eq!(effective_ttl_ms(0, CacheTier::new(63)), 0);
283        assert_eq!(effective_ttl_ms(0, CacheTier::new(255)), 0);
284    }
285
286    // =========================================================================
287    // ttl_multiplier tests
288    // =========================================================================
289
290    #[test]
291    fn ttl_multiplier_values() {
292        assert_eq!(ttl_multiplier(CacheTier::new(0)), 1);
293        assert_eq!(ttl_multiplier(CacheTier::new(1)), 2);
294        assert_eq!(ttl_multiplier(CacheTier::new(2)), 4);
295        assert_eq!(ttl_multiplier(CacheTier::new(3)), 8);
296        assert_eq!(ttl_multiplier(CacheTier::new(4)), 16);
297        assert_eq!(ttl_multiplier(CacheTier::new(5)), 32);
298        assert_eq!(ttl_multiplier(CacheTier::new(6)), 64);
299        assert_eq!(ttl_multiplier(CacheTier::new(7)), 128);
300        assert_eq!(ttl_multiplier(CacheTier::new(10)), 1024);
301        assert_eq!(ttl_multiplier(CacheTier::new(63)), 1u64 << 63);
302    }
303
304    #[test]
305    fn ttl_multiplier_caps_at_tier_63() {
306        let max = 1u64 << 63;
307        assert_eq!(ttl_multiplier(CacheTier::new(64)), max);
308        assert_eq!(ttl_multiplier(CacheTier::new(100)), max);
309        assert_eq!(ttl_multiplier(CacheTier::new(255)), max);
310    }
311
312    // =========================================================================
313    // is_expired tests
314    // =========================================================================
315
316    #[test]
317    fn is_expired_fresh_entry() {
318        let now = now_millis();
319        assert!(!is_expired_ms(now, 1000, CacheTier::new(0))); // Just inserted, 1s TTL
320        assert!(!is_expired_ms(now, 100, CacheTier::new(0))); // Just inserted, 100ms TTL (1ms too tight for test timing)
321    }
322
323    #[test]
324    fn is_expired_old_entry() {
325        let old = now_millis().saturating_sub(2000);
326        assert!(is_expired_ms(old, 1000, CacheTier::new(0))); // 2s ago, 1s TTL = expired
327        assert!(is_expired_ms(old, 1999, CacheTier::new(0))); // 2s ago, 1.999s TTL = expired
328    }
329
330    #[test]
331    fn is_expired_boundary() {
332        let inserted = now_millis().saturating_sub(1000);
333        // Exactly at TTL boundary - should be expired (>= comparison)
334        assert!(is_expired_ms(inserted, 1000, CacheTier::new(0)));
335        // Well under TTL - not expired (use 2000ms to avoid test timing issues)
336        assert!(!is_expired_ms(inserted, 2000, CacheTier::new(0)));
337    }
338
339    #[test]
340    fn is_expired_respects_tier() {
341        let inserted = now_millis().saturating_sub(1500);
342        // 1.5s ago with 1s base TTL
343        assert!(is_expired_ms(inserted, 1000, CacheTier::new(0))); // 1s TTL - expired
344        assert!(!is_expired_ms(inserted, 1000, CacheTier::new(1))); // 2s TTL - not expired
345        assert!(!is_expired_ms(inserted, 1000, CacheTier::new(2))); // 4s TTL - not expired
346    }
347
348    #[test]
349    fn is_expired_high_tier_extends_ttl() {
350        let inserted = now_millis().saturating_sub(100_000); // 100s ago
351        // With 1s base TTL
352        assert!(is_expired_ms(inserted, 1000, CacheTier::new(0))); // 1s TTL - expired
353        assert!(is_expired_ms(inserted, 1000, CacheTier::new(1))); // 2s TTL - expired
354        assert!(is_expired_ms(inserted, 1000, CacheTier::new(5))); // 32s TTL - expired
355        assert!(is_expired_ms(inserted, 1000, CacheTier::new(6))); // 64s TTL - expired
356        assert!(!is_expired_ms(inserted, 1000, CacheTier::new(7))); // 128s TTL - not expired
357        assert!(!is_expired_ms(inserted, 1000, CacheTier::new(10))); // 1024s TTL - not expired
358    }
359
360    #[test]
361    fn is_expired_zero_ttl() {
362        let now = now_millis();
363        // Zero TTL = everything expires immediately
364        assert!(is_expired_ms(now, 0, CacheTier::new(0)));
365        assert!(is_expired_ms(now, 0, CacheTier::new(63))); // Even with tier 63, 0 * 2^63 = 0
366    }
367
368    #[test]
369    fn is_expired_future_timestamp() {
370        // Edge case: timestamp in the future (clock skew)
371        let future = now_millis().saturating_add(10000);
372        // Should not be expired - elapsed would be 0 due to saturating_sub
373        assert!(!is_expired_ms(future, 1000, CacheTier::new(0)));
374    }
375
376    // =========================================================================
377    // now_millis tests
378    // =========================================================================
379
380    #[test]
381    fn now_millis_is_reasonable() {
382        let now = now_millis();
383        // Sanity check: should be after 2024-01-01 (roughly 1704067200000 ms)
384        // No upper bound to avoid time-bomb failures as years pass
385        assert!(now > 1_700_000_000_000);
386    }
387
388    #[test]
389    fn now_millis_is_monotonic() {
390        let t1 = now_millis();
391        let t2 = now_millis();
392        assert!(t2 >= t1);
393    }
394}