chie_core/
expiration.rs

1//! Automatic content expiration policies.
2//!
3//! This module provides automatic expiration management for cached content,
4//! allowing content to be automatically removed based on time-to-live (TTL),
5//! access patterns, or custom policies.
6//!
7//! # Features
8//!
9//! - TTL-based expiration
10//! - Access-based expiration (idle timeout)
11//! - Size-based expiration quotas
12//! - Custom expiration policies
13//! - Batch expiration processing
14//! - Expiration event notifications
15//!
16//! # Example
17//!
18//! ```
19//! use chie_core::expiration::{ExpirationManager, ExpirationPolicy, ContentEntry};
20//! use std::time::Duration;
21//!
22//! # fn example() {
23//! // Create an expiration manager with TTL policy
24//! let policy = ExpirationPolicy::ttl(Duration::from_secs(3600)); // 1 hour TTL
25//! let mut manager = ExpirationManager::new(policy);
26//!
27//! // Register content
28//! manager.register("content:123".to_string(), 1024);
29//!
30//! // Check for expired content
31//! let expired = manager.get_expired();
32//! println!("Expired content count: {}", expired.len());
33//! # }
34//! ```
35
36use serde::{Deserialize, Serialize};
37use std::collections::{HashMap, VecDeque};
38use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
39
40/// Default maximum entries before forced cleanup
41const DEFAULT_MAX_ENTRIES: usize = 100_000;
42
43/// Default batch size for expiration processing
44#[allow(dead_code)]
45const DEFAULT_BATCH_SIZE: usize = 1000;
46
47/// Content entry with expiration metadata
48#[derive(Debug, Clone)]
49pub struct ContentEntry {
50    /// Content identifier
51    pub cid: String,
52    /// Size in bytes
53    pub size_bytes: u64,
54    /// Timestamp when content was created
55    pub created_at: Instant,
56    /// Timestamp of last access
57    pub last_accessed: Instant,
58    /// Number of times accessed
59    pub access_count: u64,
60    /// Explicit expiration time (None = use policy)
61    pub expires_at: Option<Instant>,
62}
63
64impl ContentEntry {
65    /// Create a new content entry
66    #[must_use]
67    pub fn new(cid: String, size_bytes: u64) -> Self {
68        let now = Instant::now();
69        Self {
70            cid,
71            size_bytes,
72            created_at: now,
73            last_accessed: now,
74            access_count: 0,
75            expires_at: None,
76        }
77    }
78
79    /// Create an entry with explicit expiration time
80    #[must_use]
81    pub fn with_expiration(cid: String, size_bytes: u64, expires_at: Instant) -> Self {
82        let now = Instant::now();
83        Self {
84            cid,
85            size_bytes,
86            created_at: now,
87            last_accessed: now,
88            access_count: 0,
89            expires_at: Some(expires_at),
90        }
91    }
92
93    /// Record an access to this content
94    pub fn record_access(&mut self) {
95        self.last_accessed = Instant::now();
96        self.access_count += 1;
97    }
98
99    /// Get age of this content
100    #[must_use]
101    #[inline]
102    pub fn age(&self) -> Duration {
103        self.created_at.elapsed()
104    }
105
106    /// Get idle time (time since last access)
107    #[must_use]
108    #[inline]
109    pub fn idle_time(&self) -> Duration {
110        self.last_accessed.elapsed()
111    }
112
113    /// Check if this entry has an explicit expiration time
114    #[must_use]
115    #[inline]
116    pub const fn has_explicit_expiration(&self) -> bool {
117        self.expires_at.is_some()
118    }
119}
120
121/// Expiration policy
122#[derive(Debug, Clone, Default)]
123pub enum ExpirationPolicy {
124    /// Time-to-live: expire after fixed duration from creation
125    Ttl(Duration),
126
127    /// Idle timeout: expire after no access for duration
128    IdleTimeout(Duration),
129
130    /// Least recently used: keep only N most recently accessed items
131    Lru(usize),
132
133    /// Size quota: keep content up to max bytes
134    SizeQuota(u64),
135
136    /// Combined: expire if any condition is met
137    Combined(Vec<ExpirationPolicy>),
138
139    /// Never expire
140    #[default]
141    Never,
142}
143
144impl ExpirationPolicy {
145    /// Create a TTL policy
146    #[must_use]
147    pub const fn ttl(duration: Duration) -> Self {
148        Self::Ttl(duration)
149    }
150
151    /// Create an idle timeout policy
152    #[must_use]
153    pub const fn idle_timeout(duration: Duration) -> Self {
154        Self::IdleTimeout(duration)
155    }
156
157    /// Create an LRU policy
158    #[must_use]
159    pub const fn lru(max_entries: usize) -> Self {
160        Self::Lru(max_entries)
161    }
162
163    /// Create a size quota policy
164    #[must_use]
165    pub const fn size_quota(max_bytes: u64) -> Self {
166        Self::SizeQuota(max_bytes)
167    }
168
169    /// Create a combined policy
170    #[must_use]
171    pub fn combined(policies: Vec<Self>) -> Self {
172        Self::Combined(policies)
173    }
174
175    /// Check if an entry should be expired according to this policy
176    #[must_use]
177    #[inline]
178    pub fn should_expire(&self, entry: &ContentEntry) -> bool {
179        // Check explicit expiration first
180        if let Some(expires_at) = entry.expires_at {
181            if Instant::now() >= expires_at {
182                return true;
183            }
184        }
185
186        match self {
187            Self::Ttl(duration) => entry.age() >= *duration,
188            Self::IdleTimeout(duration) => entry.idle_time() >= *duration,
189            Self::Combined(policies) => policies.iter().any(|p| p.should_expire(entry)),
190            Self::Never | Self::Lru(_) | Self::SizeQuota(_) => false,
191        }
192    }
193}
194
195/// Statistics for expiration operations
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197pub struct ExpirationStats {
198    /// Total entries currently tracked
199    pub total_entries: usize,
200    /// Total bytes tracked
201    pub total_bytes: u64,
202    /// Number of expired entries removed
203    pub expired_count: u64,
204    /// Total bytes freed by expiration
205    pub bytes_freed: u64,
206    /// Number of expiration checks performed
207    pub checks_performed: u64,
208    /// Last expiration check timestamp
209    pub last_check_ms: u64,
210}
211
212/// Manages automatic content expiration
213pub struct ExpirationManager {
214    /// Expiration policy
215    policy: ExpirationPolicy,
216    /// Content entries (cid -> entry)
217    entries: HashMap<String, ContentEntry>,
218    /// Access order queue (for LRU)
219    access_order: VecDeque<String>,
220    /// Statistics
221    stats: ExpirationStats,
222    /// Maximum entries before forced cleanup
223    max_entries: usize,
224}
225
226impl ExpirationManager {
227    /// Create a new expiration manager with a policy
228    #[must_use]
229    pub fn new(policy: ExpirationPolicy) -> Self {
230        Self {
231            policy,
232            entries: HashMap::new(),
233            access_order: VecDeque::new(),
234            stats: ExpirationStats::default(),
235            max_entries: DEFAULT_MAX_ENTRIES,
236        }
237    }
238
239    /// Create an expiration manager with custom max entries
240    #[must_use]
241    pub fn with_max_entries(policy: ExpirationPolicy, max_entries: usize) -> Self {
242        Self {
243            policy,
244            entries: HashMap::new(),
245            access_order: VecDeque::new(),
246            stats: ExpirationStats::default(),
247            max_entries,
248        }
249    }
250
251    /// Register content for expiration tracking
252    pub fn register(&mut self, cid: String, size_bytes: u64) {
253        let entry = ContentEntry::new(cid.clone(), size_bytes);
254        self.insert_entry(entry);
255    }
256
257    /// Register content with explicit expiration time
258    pub fn register_with_expiration(&mut self, cid: String, size_bytes: u64, expires_at: Instant) {
259        let entry = ContentEntry::with_expiration(cid.clone(), size_bytes, expires_at);
260        self.insert_entry(entry);
261    }
262
263    /// Insert an entry (internal helper)
264    fn insert_entry(&mut self, entry: ContentEntry) {
265        let cid = entry.cid.clone();
266        let size = entry.size_bytes;
267
268        self.entries.insert(cid.clone(), entry);
269        self.access_order.push_back(cid);
270
271        self.stats.total_entries = self.entries.len();
272        self.stats.total_bytes += size;
273
274        // Enforce max entries limit
275        if self.entries.len() > self.max_entries {
276            self.expire_oldest();
277        }
278    }
279
280    /// Record access to content
281    pub fn record_access(&mut self, cid: &str) {
282        if let Some(entry) = self.entries.get_mut(cid) {
283            entry.record_access();
284
285            // Update LRU order
286            if let Some(pos) = self.access_order.iter().position(|c| c == cid) {
287                self.access_order.remove(pos);
288                self.access_order.push_back(cid.to_string());
289            }
290        }
291    }
292
293    /// Get all expired content IDs
294    #[must_use]
295    pub fn get_expired(&mut self) -> Vec<String> {
296        self.stats.checks_performed += 1;
297        self.stats.last_check_ms = current_timestamp_ms();
298
299        let mut expired = Vec::new();
300
301        // Check time-based expiration
302        for (cid, entry) in &self.entries {
303            if self.policy.should_expire(entry) {
304                expired.push(cid.clone());
305            }
306        }
307
308        // Check LRU policy
309        if let ExpirationPolicy::Lru(max_entries) = self.policy {
310            if self.entries.len() > max_entries {
311                let to_remove = self.entries.len() - max_entries;
312                for cid in self.access_order.iter().take(to_remove) {
313                    if !expired.contains(cid) {
314                        expired.push(cid.clone());
315                    }
316                }
317            }
318        }
319
320        // Check size quota policy
321        if let ExpirationPolicy::SizeQuota(max_bytes) = self.policy {
322            if self.stats.total_bytes > max_bytes {
323                let mut bytes_to_free = self.stats.total_bytes - max_bytes;
324                for cid in &self.access_order {
325                    if bytes_to_free == 0 {
326                        break;
327                    }
328                    if let Some(entry) = self.entries.get(cid) {
329                        if !expired.contains(cid) {
330                            expired.push(cid.clone());
331                            bytes_to_free = bytes_to_free.saturating_sub(entry.size_bytes);
332                        }
333                    }
334                }
335            }
336        }
337
338        expired
339    }
340
341    /// Remove expired content (returns list of removed CIDs)
342    pub fn expire(&mut self) -> Vec<String> {
343        let expired = self.get_expired();
344        for cid in &expired {
345            self.remove(cid);
346        }
347        expired
348    }
349
350    /// Remove expired content in batches
351    pub fn expire_batch(&mut self, batch_size: usize) -> Vec<String> {
352        let expired = self.get_expired();
353        let to_remove: Vec<_> = expired.into_iter().take(batch_size).collect();
354
355        for cid in &to_remove {
356            self.remove(cid);
357        }
358
359        to_remove
360    }
361
362    /// Remove a specific content entry
363    pub fn remove(&mut self, cid: &str) -> Option<ContentEntry> {
364        if let Some(entry) = self.entries.remove(cid) {
365            // Update stats
366            self.stats.total_entries = self.entries.len();
367            self.stats.total_bytes = self.stats.total_bytes.saturating_sub(entry.size_bytes);
368            self.stats.expired_count += 1;
369            self.stats.bytes_freed += entry.size_bytes;
370
371            // Remove from access order
372            if let Some(pos) = self.access_order.iter().position(|c| c == cid) {
373                self.access_order.remove(pos);
374            }
375
376            Some(entry)
377        } else {
378            None
379        }
380    }
381
382    /// Expire oldest entries until under max_entries
383    fn expire_oldest(&mut self) {
384        while self.entries.len() > self.max_entries {
385            if let Some(cid) = self.access_order.pop_front() {
386                self.remove(&cid);
387            } else {
388                break;
389            }
390        }
391    }
392
393    /// Get current statistics
394    #[must_use]
395    #[inline]
396    pub fn stats(&self) -> &ExpirationStats {
397        &self.stats
398    }
399
400    /// Get the number of tracked entries
401    #[must_use]
402    #[inline]
403    pub fn entry_count(&self) -> usize {
404        self.entries.len()
405    }
406
407    /// Get total bytes tracked
408    #[must_use]
409    #[inline]
410    pub fn total_bytes(&self) -> u64 {
411        self.stats.total_bytes
412    }
413
414    /// Check if a CID is being tracked
415    #[must_use]
416    #[inline]
417    pub fn contains(&self, cid: &str) -> bool {
418        self.entries.contains_key(cid)
419    }
420
421    /// Get an entry by CID
422    #[must_use]
423    #[inline]
424    pub fn get(&self, cid: &str) -> Option<&ContentEntry> {
425        self.entries.get(cid)
426    }
427
428    /// Clear all entries
429    pub fn clear(&mut self) {
430        self.entries.clear();
431        self.access_order.clear();
432        self.stats.total_entries = 0;
433        self.stats.total_bytes = 0;
434    }
435
436    /// Update the expiration policy
437    pub fn set_policy(&mut self, policy: ExpirationPolicy) {
438        self.policy = policy;
439    }
440}
441
442/// Get current timestamp in milliseconds
443fn current_timestamp_ms() -> u64 {
444    SystemTime::now()
445        .duration_since(UNIX_EPOCH)
446        .unwrap_or_default()
447        .as_millis() as u64
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use std::thread::sleep;
454
455    #[test]
456    fn test_content_entry_new() {
457        let entry = ContentEntry::new("test:123".to_string(), 1024);
458        assert_eq!(entry.cid, "test:123");
459        assert_eq!(entry.size_bytes, 1024);
460        assert_eq!(entry.access_count, 0);
461    }
462
463    #[test]
464    fn test_content_entry_access() {
465        let mut entry = ContentEntry::new("test:123".to_string(), 1024);
466        entry.record_access();
467        assert_eq!(entry.access_count, 1);
468    }
469
470    #[test]
471    fn test_expiration_policy_ttl() {
472        let policy = ExpirationPolicy::ttl(Duration::from_millis(100));
473        let entry = ContentEntry::new("test:123".to_string(), 1024);
474
475        // Should not expire immediately
476        assert!(!policy.should_expire(&entry));
477
478        // Should expire after TTL
479        sleep(Duration::from_millis(150));
480        assert!(policy.should_expire(&entry));
481    }
482
483    #[test]
484    fn test_expiration_policy_idle_timeout() {
485        let policy = ExpirationPolicy::idle_timeout(Duration::from_millis(100));
486        let mut entry = ContentEntry::new("test:123".to_string(), 1024);
487
488        sleep(Duration::from_millis(150));
489        assert!(policy.should_expire(&entry));
490
491        // Access should reset idle timer
492        entry.record_access();
493        assert!(!policy.should_expire(&entry));
494    }
495
496    #[test]
497    fn test_expiration_manager_register() {
498        let policy = ExpirationPolicy::Never;
499        let mut manager = ExpirationManager::new(policy);
500
501        manager.register("test:123".to_string(), 1024);
502
503        assert_eq!(manager.entry_count(), 1);
504        assert_eq!(manager.total_bytes(), 1024);
505        assert!(manager.contains("test:123"));
506    }
507
508    #[test]
509    fn test_expiration_manager_expire_ttl() {
510        let policy = ExpirationPolicy::ttl(Duration::from_millis(100));
511        let mut manager = ExpirationManager::new(policy);
512
513        manager.register("test:123".to_string(), 1024);
514
515        // Should not expire immediately
516        let expired = manager.get_expired();
517        assert_eq!(expired.len(), 0);
518
519        // Should expire after TTL
520        sleep(Duration::from_millis(150));
521        let expired = manager.expire();
522        assert_eq!(expired.len(), 1);
523        assert_eq!(expired[0], "test:123");
524        assert_eq!(manager.entry_count(), 0);
525    }
526
527    #[test]
528    fn test_expiration_manager_lru() {
529        let policy = ExpirationPolicy::lru(2);
530        let mut manager = ExpirationManager::new(policy);
531
532        manager.register("test:1".to_string(), 1024);
533        manager.register("test:2".to_string(), 1024);
534        manager.register("test:3".to_string(), 1024);
535
536        // Should expire oldest entry when over LRU limit
537        let expired = manager.expire();
538        assert_eq!(expired.len(), 1);
539        assert_eq!(expired[0], "test:1");
540        assert_eq!(manager.entry_count(), 2);
541    }
542
543    #[test]
544    fn test_expiration_manager_size_quota() {
545        let policy = ExpirationPolicy::size_quota(2000);
546        let mut manager = ExpirationManager::new(policy);
547
548        manager.register("test:1".to_string(), 1000);
549        manager.register("test:2".to_string(), 1000);
550        manager.register("test:3".to_string(), 1000);
551
552        // Should expire until under quota
553        let expired = manager.expire();
554        assert!(!expired.is_empty());
555        assert!(manager.total_bytes() <= 2000);
556    }
557
558    #[test]
559    fn test_expiration_manager_record_access() {
560        let policy = ExpirationPolicy::Never;
561        let mut manager = ExpirationManager::new(policy);
562
563        manager.register("test:123".to_string(), 1024);
564        manager.record_access("test:123");
565
566        let entry = manager.get("test:123").unwrap();
567        assert_eq!(entry.access_count, 1);
568    }
569
570    #[test]
571    fn test_expiration_manager_remove() {
572        let policy = ExpirationPolicy::Never;
573        let mut manager = ExpirationManager::new(policy);
574
575        manager.register("test:123".to_string(), 1024);
576        assert_eq!(manager.entry_count(), 1);
577
578        let removed = manager.remove("test:123");
579        assert!(removed.is_some());
580        assert_eq!(manager.entry_count(), 0);
581    }
582
583    #[test]
584    fn test_expiration_manager_stats() {
585        let policy = ExpirationPolicy::ttl(Duration::from_millis(100));
586        let mut manager = ExpirationManager::new(policy);
587
588        manager.register("test:1".to_string(), 1024);
589        manager.register("test:2".to_string(), 2048);
590
591        sleep(Duration::from_millis(150));
592        let _expired = manager.expire();
593
594        let stats = manager.stats();
595        assert_eq!(stats.expired_count, 2);
596        assert_eq!(stats.bytes_freed, 3072);
597    }
598
599    #[test]
600    fn test_expiration_manager_batch() {
601        let policy = ExpirationPolicy::ttl(Duration::from_millis(100));
602        let mut manager = ExpirationManager::new(policy);
603
604        for i in 0..10 {
605            manager.register(format!("test:{i}"), 1024);
606        }
607
608        sleep(Duration::from_millis(150));
609
610        // Expire in batches of 3
611        let batch1 = manager.expire_batch(3);
612        assert_eq!(batch1.len(), 3);
613
614        let batch2 = manager.expire_batch(3);
615        assert_eq!(batch2.len(), 3);
616    }
617
618    #[test]
619    fn test_expiration_manager_max_entries() {
620        let policy = ExpirationPolicy::Never;
621        let mut manager = ExpirationManager::with_max_entries(policy, 5);
622
623        for i in 0..10 {
624            manager.register(format!("test:{i}"), 1024);
625        }
626
627        // Should automatically expire oldest when over max
628        assert_eq!(manager.entry_count(), 5);
629    }
630
631    #[test]
632    fn test_explicit_expiration() {
633        let policy = ExpirationPolicy::Never;
634        let mut manager = ExpirationManager::new(policy);
635
636        let expires_at = Instant::now() + Duration::from_millis(100);
637        manager.register_with_expiration("test:123".to_string(), 1024, expires_at);
638
639        // Should not expire immediately
640        let expired = manager.get_expired();
641        assert_eq!(expired.len(), 0);
642
643        // Should expire after explicit time
644        sleep(Duration::from_millis(150));
645        let expired = manager.expire();
646        assert_eq!(expired.len(), 1);
647    }
648}