Skip to main content

crates_docs/tools/docs/cache/
ttl.rs

1//! TTL (Time-To-Live) management for document cache
2
3use std::time::Duration;
4
5/// Default TTL jitter ratio (10%)
6///
7/// # Value
8///
9/// 0.1 (10%)
10///
11/// # Rationale
12///
13/// A 10% jitter helps prevent cache stampede when multiple requests expire simultaneously.
14/// This spreads the load over time while maintaining reasonable cache consistency.
15/// Configurable via `DocCacheTtl::jitter_ratio` field.
16const DEFAULT_JITTER_RATIO: f64 = 0.1;
17
18/// Minimum valid jitter ratio
19///
20/// Must be > 0.0 to have any effect. A value of 0.0 disables jitter.
21const MIN_JITTER_RATIO: f64 = 0.0;
22
23/// Maximum valid jitter ratio
24///
25/// Must be <= 1.0 (100%) to prevent negative or excessive TTL values.
26const MAX_JITTER_RATIO: f64 = 1.0;
27
28/// Default crate documentation TTL in seconds
29///
30/// # Value
31///
32/// 3600 seconds (1 hour)
33///
34/// # Rationale
35///
36/// Crate documentation changes infrequently and is relatively large, making it suitable for longer caching.
37/// This reduces load on docs.rs while ensuring reasonable freshness.
38/// Configurable via `CacheConfig::crate_docs_ttl_secs`.
39const DEFAULT_CRATE_DOCS_TTL_SECS: u64 = 3600;
40
41/// Default search results TTL in seconds
42///
43/// # Value
44///
45/// 300 seconds (5 minutes)
46///
47/// # Rationale
48///
49/// Search results change frequently as new crates are published.
50/// Short TTL ensures users see recent additions while still benefiting from caching.
51/// Configurable via `CacheConfig::search_results_ttl_secs`.
52const DEFAULT_SEARCH_RESULTS_TTL_SECS: u64 = 300;
53
54/// Default item documentation TTL in seconds
55///
56/// # Value
57///
58/// 1800 seconds (30 minutes)
59///
60/// # Rationale
61///
62/// Item documentation (functions, structs) changes moderately often.
63/// Medium TTL balances freshness with performance.
64/// Configurable via `CacheConfig::item_docs_ttl_secs`.
65const DEFAULT_ITEM_DOCS_TTL_SECS: u64 = 1800;
66
67/// Document cache TTL configuration
68///
69/// Configure independent TTL for different document types.
70///
71/// # Fields
72///
73/// - `crate_docs_secs`: Crate document cache duration (seconds)
74/// - `search_results_secs`: search results cache duration (seconds)
75/// - `item_docs_secs`: item docs cache duration (seconds)
76/// - `jitter_ratio`: TTL jitter ratio(0.0-1.0),used to prevent cache stampede
77#[derive(Debug, Clone, Copy)]
78pub struct DocCacheTtl {
79    /// Crate document TTL (seconds)
80    pub crate_docs_secs: u64,
81    /// Search results TTL (seconds)
82    pub search_results_secs: u64,
83    /// Item documentation TTL (seconds)
84    pub item_docs_secs: u64,
85    /// TTL jitter ratio (0.0-1.0), default 0.1 (10%)
86    ///
87    /// Actual TTL = `base_ttl * (1 + random(-jitter_ratio, jitter_ratio))`
88    /// for example:`base_ttl=3600`, `jitter_ratio=0.1` => Actual TTL range `[3240, 3960]`
89    ///
90    /// Use `set_jitter_ratio()` to modify this value with validation.
91    jitter_ratio: f64,
92}
93
94impl Default for DocCacheTtl {
95    fn default() -> Self {
96        Self {
97            crate_docs_secs: DEFAULT_CRATE_DOCS_TTL_SECS,
98            search_results_secs: DEFAULT_SEARCH_RESULTS_TTL_SECS,
99            item_docs_secs: DEFAULT_ITEM_DOCS_TTL_SECS,
100            jitter_ratio: DEFAULT_JITTER_RATIO,
101        }
102    }
103}
104
105impl DocCacheTtl {
106    /// Create TTL configuration from `CacheConfig`
107    ///
108    /// # Arguments
109    ///
110    /// * `config` - cache configuration
111    ///
112    /// # Returns
113    ///
114    /// Returns TTL configuration based on config with validated `jitter_ratio`
115    #[must_use]
116    pub fn from_cache_config(config: &crate::cache::CacheConfig) -> Self {
117        Self {
118            crate_docs_secs: config
119                .crate_docs_ttl_secs
120                .unwrap_or(DEFAULT_CRATE_DOCS_TTL_SECS),
121            search_results_secs: config
122                .search_results_ttl_secs
123                .unwrap_or(DEFAULT_SEARCH_RESULTS_TTL_SECS),
124            item_docs_secs: config
125                .item_docs_ttl_secs
126                .unwrap_or(DEFAULT_ITEM_DOCS_TTL_SECS),
127            jitter_ratio: DEFAULT_JITTER_RATIO,
128        }
129    }
130
131    /// Create TTL configuration with custom jitter ratio
132    ///
133    /// # Arguments
134    ///
135    /// * `crate_docs_secs` - Crate docs TTL in seconds
136    /// * `search_results_secs` - Search results TTL in seconds
137    /// * `item_docs_secs` - Item docs TTL in seconds
138    /// * `jitter_ratio` - Jitter ratio (0.0-1.0), out-of-range values are clamped
139    ///
140    /// # Returns
141    ///
142    /// Returns TTL configuration with validated and clamped `jitter_ratio`
143    #[must_use]
144    pub fn with_jitter(
145        crate_docs_secs: u64,
146        search_results_secs: u64,
147        item_docs_secs: u64,
148        jitter_ratio: f64,
149    ) -> Self {
150        Self {
151            crate_docs_secs,
152            search_results_secs,
153            item_docs_secs,
154            jitter_ratio: Self::validate_jitter_ratio(jitter_ratio),
155        }
156    }
157
158    /// Validate and clamp jitter ratio to valid range
159    ///
160    /// Ensures `jitter_ratio` is within `[MIN_JITTER_RATIO, MAX_JITTER_RATIO]`.
161    /// Values outside this range are clamped to the nearest valid value.
162    ///
163    /// # Arguments
164    ///
165    /// * `ratio` - The jitter ratio to validate
166    ///
167    /// # Returns
168    ///
169    /// Returns clamped jitter ratio in range [0.0, 1.0]
170    #[must_use]
171    fn validate_jitter_ratio(ratio: f64) -> f64 {
172        if ratio.is_nan() || ratio < MIN_JITTER_RATIO {
173            MIN_JITTER_RATIO
174        } else if ratio > MAX_JITTER_RATIO {
175            MAX_JITTER_RATIO
176        } else {
177            ratio
178        }
179    }
180
181    /// Get the current jitter ratio
182    #[must_use]
183    pub const fn jitter_ratio(&self) -> f64 {
184        self.jitter_ratio
185    }
186
187    /// Set the jitter ratio with validation
188    ///
189    /// Values outside [0.0, 1.0] range are clamped to the nearest valid value.
190    /// NaN values are treated as 0.0.
191    ///
192    /// # Arguments
193    ///
194    /// * `ratio` - The jitter ratio to set
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use crates_docs::tools::docs::cache::DocCacheTtl;
200    ///
201    /// let mut ttl = DocCacheTtl::default();
202    /// ttl.set_jitter_ratio(0.2);
203    /// assert!((ttl.jitter_ratio() - 0.2).abs() < f64::EPSILON);
204    ///
205    /// // Out of range values are clamped
206    /// ttl.set_jitter_ratio(1.5);
207    /// assert!((ttl.jitter_ratio() - 1.0).abs() < f64::EPSILON);
208    /// ```
209    pub fn set_jitter_ratio(&mut self, ratio: f64) {
210        self.jitter_ratio = Self::validate_jitter_ratio(ratio);
211    }
212
213    /// Calculate actual TTL with jitter
214    ///
215    /// # Arguments
216    ///
217    /// * `base_ttl` - Base TTL (seconds)
218    ///
219    /// # Returns
220    ///
221    /// Returns jittered TTL (seconds)
222    #[must_use]
223    #[allow(clippy::cast_possible_truncation)]
224    #[allow(clippy::cast_sign_loss)]
225    #[allow(clippy::cast_precision_loss)]
226    pub fn apply_jitter(&self, base_ttl: u64) -> u64 {
227        // Clamp jitter_ratio to valid range for safety (handles direct struct construction)
228        let ratio = self.jitter_ratio.clamp(MIN_JITTER_RATIO, MAX_JITTER_RATIO);
229
230        if ratio <= MIN_JITTER_RATIO {
231            return base_ttl;
232        }
233
234        let rng = fastrand::f64();
235        let offset = (rng * 2.0 - 1.0) * ratio;
236
237        (base_ttl as f64 * (1.0 + offset)).max(1.0) as u64
238    }
239
240    /// Get TTL duration for crate docs with jitter applied
241    #[must_use]
242    pub fn crate_docs_duration(&self) -> Duration {
243        Duration::from_secs(self.apply_jitter(self.crate_docs_secs))
244    }
245
246    /// Get TTL duration for search results with jitter applied
247    #[must_use]
248    pub fn search_results_duration(&self) -> Duration {
249        Duration::from_secs(self.apply_jitter(self.search_results_secs))
250    }
251
252    /// Get TTL duration for item docs with jitter applied
253    #[must_use]
254    pub fn item_docs_duration(&self) -> Duration {
255        Duration::from_secs(self.apply_jitter(self.item_docs_secs))
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_doc_cache_ttl_default() {
265        let ttl = DocCacheTtl::default();
266        assert_eq!(ttl.crate_docs_secs, DEFAULT_CRATE_DOCS_TTL_SECS);
267        assert_eq!(ttl.search_results_secs, DEFAULT_SEARCH_RESULTS_TTL_SECS);
268        assert_eq!(ttl.item_docs_secs, DEFAULT_ITEM_DOCS_TTL_SECS);
269        assert!((ttl.jitter_ratio() - DEFAULT_JITTER_RATIO).abs() < f64::EPSILON);
270    }
271
272    #[test]
273    fn test_doc_cache_ttl_from_config() {
274        let config = crate::cache::CacheConfig {
275            cache_type: "memory".to_string(),
276            memory_size: Some(1000),
277            redis_url: None,
278            key_prefix: String::new(),
279            default_ttl: Some(DEFAULT_CRATE_DOCS_TTL_SECS),
280            crate_docs_ttl_secs: Some(7200),
281            item_docs_ttl_secs: Some(DEFAULT_CRATE_DOCS_TTL_SECS),
282            search_results_ttl_secs: Some(600),
283        };
284        let ttl = DocCacheTtl::from_cache_config(&config);
285        assert_eq!(ttl.crate_docs_secs, 7200);
286        assert_eq!(ttl.item_docs_secs, DEFAULT_CRATE_DOCS_TTL_SECS);
287        assert_eq!(ttl.search_results_secs, 600);
288    }
289
290    #[test]
291    fn test_apply_jitter_no_jitter() {
292        let mut ttl = DocCacheTtl::default();
293        ttl.set_jitter_ratio(0.0);
294        assert_eq!(ttl.apply_jitter(1000), 1000);
295    }
296
297    #[test]
298    fn test_apply_jitter_with_jitter() {
299        let mut ttl = DocCacheTtl::default();
300        ttl.set_jitter_ratio(0.5);
301
302        for _ in 0..100 {
303            let jittered = ttl.apply_jitter(1000);
304            assert!((500..=1500).contains(&jittered));
305        }
306    }
307
308    #[test]
309    fn test_durations() {
310        let mut ttl = DocCacheTtl::default();
311        ttl.set_jitter_ratio(0.0);
312        ttl.crate_docs_secs = DEFAULT_CRATE_DOCS_TTL_SECS;
313        ttl.search_results_secs = DEFAULT_SEARCH_RESULTS_TTL_SECS;
314        ttl.item_docs_secs = DEFAULT_ITEM_DOCS_TTL_SECS;
315
316        assert_eq!(
317            ttl.crate_docs_duration(),
318            Duration::from_secs(DEFAULT_CRATE_DOCS_TTL_SECS)
319        );
320        assert_eq!(
321            ttl.search_results_duration(),
322            Duration::from_secs(DEFAULT_SEARCH_RESULTS_TTL_SECS)
323        );
324        assert_eq!(
325            ttl.item_docs_duration(),
326            Duration::from_secs(DEFAULT_ITEM_DOCS_TTL_SECS)
327        );
328    }
329
330    #[test]
331    fn test_jitter_ratio_setter_validation() {
332        let mut ttl = DocCacheTtl::default();
333
334        // Valid values should be accepted
335        ttl.set_jitter_ratio(0.5);
336        assert!((ttl.jitter_ratio() - 0.5).abs() < f64::EPSILON);
337
338        // Value at boundaries should be accepted
339        ttl.set_jitter_ratio(0.0);
340        assert!((ttl.jitter_ratio()).abs() < f64::EPSILON);
341
342        ttl.set_jitter_ratio(1.0);
343        assert!((ttl.jitter_ratio() - 1.0).abs() < f64::EPSILON);
344    }
345
346    #[test]
347    fn test_jitter_ratio_clamping() {
348        let mut ttl = DocCacheTtl::default();
349
350        // Values > 1.0 should be clamped to 1.0
351        ttl.set_jitter_ratio(1.5);
352        assert!((ttl.jitter_ratio() - 1.0).abs() < f64::EPSILON);
353
354        ttl.set_jitter_ratio(100.0);
355        assert!((ttl.jitter_ratio() - 1.0).abs() < f64::EPSILON);
356
357        // Negative values should be clamped to 0.0
358        ttl.set_jitter_ratio(-0.1);
359        assert!(ttl.jitter_ratio().abs() < f64::EPSILON);
360
361        ttl.set_jitter_ratio(-100.0);
362        assert!(ttl.jitter_ratio().abs() < f64::EPSILON);
363    }
364
365    #[test]
366    fn test_jitter_ratio_nan_handling() {
367        let mut ttl = DocCacheTtl::default();
368
369        // NaN should be treated as 0.0
370        ttl.set_jitter_ratio(f64::NAN);
371        assert!(ttl.jitter_ratio().abs() < f64::EPSILON);
372    }
373
374    #[test]
375    fn test_jitter_ratio_infinity_handling() {
376        let mut ttl = DocCacheTtl::default();
377
378        // Positive infinity should be clamped to 1.0
379        ttl.set_jitter_ratio(f64::INFINITY);
380        assert!((ttl.jitter_ratio() - 1.0).abs() < f64::EPSILON);
381
382        // Negative infinity should be clamped to 0.0
383        ttl.set_jitter_ratio(f64::NEG_INFINITY);
384        assert!(ttl.jitter_ratio().abs() < f64::EPSILON);
385    }
386
387    #[test]
388    fn test_apply_jitter_with_extreme_values() {
389        // Test with jitter_ratio = 0.0 (no jitter)
390        let mut ttl = DocCacheTtl::default();
391        ttl.set_jitter_ratio(0.0);
392        assert_eq!(ttl.apply_jitter(1000), 1000);
393
394        // Test with jitter_ratio = 1.0 (max jitter, range [0, 2000])
395        ttl.set_jitter_ratio(1.0);
396        for _ in 0..100 {
397            let jittered = ttl.apply_jitter(1000);
398            assert!((0..=2000).contains(&jittered));
399        }
400    }
401}