Skip to main content

oximedia_proxy/
proxy_aging.rs

1#![allow(dead_code)]
2//! Age-based proxy lifecycle management and expiration.
3//!
4//! Manages proxy files through their lifecycle from creation to deletion,
5//! applying aging policies that automatically expire, archive, or
6//! regenerate proxies based on configurable rules.
7
8use std::collections::HashMap;
9
10/// Lifecycle stage of a proxy file.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum ProxyStage {
13    /// Freshly created, actively in use.
14    Active,
15    /// Not accessed recently but still available.
16    Idle,
17    /// Marked for archival or cold storage.
18    Stale,
19    /// Scheduled for deletion.
20    Expired,
21    /// Archived to cold storage.
22    Archived,
23    /// Deleted.
24    Deleted,
25}
26
27impl ProxyStage {
28    /// Human-readable label.
29    pub fn label(&self) -> &'static str {
30        match self {
31            Self::Active => "Active",
32            Self::Idle => "Idle",
33            Self::Stale => "Stale",
34            Self::Expired => "Expired",
35            Self::Archived => "Archived",
36            Self::Deleted => "Deleted",
37        }
38    }
39
40    /// Whether the proxy is still usable.
41    pub fn is_usable(&self) -> bool {
42        matches!(self, Self::Active | Self::Idle)
43    }
44}
45
46/// Aging policy configuration.
47#[derive(Debug, Clone)]
48pub struct AgingPolicy {
49    /// Days after last access before becoming idle.
50    pub idle_after_days: u64,
51    /// Days after last access before becoming stale.
52    pub stale_after_days: u64,
53    /// Days after last access before expiration.
54    pub expire_after_days: u64,
55    /// Whether to auto-archive stale proxies.
56    pub auto_archive: bool,
57    /// Whether to auto-delete expired proxies.
58    pub auto_delete: bool,
59    /// Minimum size in bytes to apply aging (skip tiny files).
60    pub min_size_bytes: u64,
61}
62
63impl Default for AgingPolicy {
64    fn default() -> Self {
65        Self {
66            idle_after_days: 7,
67            stale_after_days: 30,
68            expire_after_days: 90,
69            auto_archive: true,
70            auto_delete: false,
71            min_size_bytes: 1024,
72        }
73    }
74}
75
76impl AgingPolicy {
77    /// Create a strict policy for space-constrained environments.
78    pub fn strict() -> Self {
79        Self {
80            idle_after_days: 3,
81            stale_after_days: 14,
82            expire_after_days: 30,
83            auto_archive: true,
84            auto_delete: true,
85            min_size_bytes: 0,
86        }
87    }
88
89    /// Create a relaxed policy for archival workflows.
90    pub fn relaxed() -> Self {
91        Self {
92            idle_after_days: 30,
93            stale_after_days: 180,
94            expire_after_days: 365,
95            auto_archive: false,
96            auto_delete: false,
97            min_size_bytes: 0,
98        }
99    }
100}
101
102/// Metadata for a managed proxy file.
103#[derive(Debug, Clone)]
104pub struct ProxyRecord {
105    /// Proxy file identifier.
106    pub id: String,
107    /// File path.
108    pub path: String,
109    /// File size in bytes.
110    pub size_bytes: u64,
111    /// Creation timestamp (days since epoch).
112    pub created_day: u64,
113    /// Last access timestamp (days since epoch).
114    pub last_access_day: u64,
115    /// Number of times accessed.
116    pub access_count: u64,
117    /// Current lifecycle stage.
118    pub stage: ProxyStage,
119}
120
121impl ProxyRecord {
122    /// Create a new proxy record.
123    pub fn new(id: &str, path: &str, size_bytes: u64, created_day: u64) -> Self {
124        Self {
125            id: id.to_string(),
126            path: path.to_string(),
127            size_bytes,
128            created_day,
129            last_access_day: created_day,
130            access_count: 0,
131            stage: ProxyStage::Active,
132        }
133    }
134
135    /// Record an access event.
136    pub fn record_access(&mut self, day: u64) {
137        self.last_access_day = day;
138        self.access_count += 1;
139        // Accessing a proxy reactivates it
140        if self.stage.is_usable() || self.stage == ProxyStage::Stale {
141            self.stage = ProxyStage::Active;
142        }
143    }
144
145    /// Days since last access relative to the given day.
146    pub fn days_since_access(&self, current_day: u64) -> u64 {
147        current_day.saturating_sub(self.last_access_day)
148    }
149
150    /// Age in days since creation relative to the given day.
151    pub fn age_days(&self, current_day: u64) -> u64 {
152        current_day.saturating_sub(self.created_day)
153    }
154}
155
156/// Result of an aging sweep.
157#[derive(Debug, Clone)]
158pub struct AgingSweepResult {
159    /// Number of proxies transitioned to idle.
160    pub newly_idle: usize,
161    /// Number of proxies transitioned to stale.
162    pub newly_stale: usize,
163    /// Number of proxies transitioned to expired.
164    pub newly_expired: usize,
165    /// Number of proxies archived.
166    pub archived: usize,
167    /// Number of proxies deleted.
168    pub deleted: usize,
169    /// Total bytes reclaimed.
170    pub bytes_reclaimed: u64,
171}
172
173impl AgingSweepResult {
174    /// Total number of transitions.
175    pub fn total_transitions(&self) -> usize {
176        self.newly_idle + self.newly_stale + self.newly_expired + self.archived + self.deleted
177    }
178}
179
180/// Manager for proxy aging lifecycle.
181pub struct AgingManager {
182    /// Aging policy.
183    policy: AgingPolicy,
184    /// Managed proxy records.
185    records: HashMap<String, ProxyRecord>,
186}
187
188impl AgingManager {
189    /// Create a new aging manager with the given policy.
190    pub fn new(policy: AgingPolicy) -> Self {
191        Self {
192            policy,
193            records: HashMap::new(),
194        }
195    }
196
197    /// Add a proxy record.
198    pub fn add_record(&mut self, record: ProxyRecord) {
199        self.records.insert(record.id.clone(), record);
200    }
201
202    /// Get a record by ID.
203    pub fn get_record(&self, id: &str) -> Option<&ProxyRecord> {
204        self.records.get(id)
205    }
206
207    /// Record an access event for a proxy.
208    pub fn record_access(&mut self, id: &str, day: u64) -> bool {
209        if let Some(record) = self.records.get_mut(id) {
210            record.record_access(day);
211            true
212        } else {
213            false
214        }
215    }
216
217    /// Total number of managed records.
218    pub fn record_count(&self) -> usize {
219        self.records.len()
220    }
221
222    /// Total size of all managed proxies in bytes.
223    pub fn total_size_bytes(&self) -> u64 {
224        self.records.values().map(|r| r.size_bytes).sum()
225    }
226
227    /// Run an aging sweep for the given current day.
228    pub fn sweep(&mut self, current_day: u64) -> AgingSweepResult {
229        let mut result = AgingSweepResult {
230            newly_idle: 0,
231            newly_stale: 0,
232            newly_expired: 0,
233            archived: 0,
234            deleted: 0,
235            bytes_reclaimed: 0,
236        };
237
238        let mut to_delete = Vec::new();
239
240        for record in self.records.values_mut() {
241            if record.size_bytes < self.policy.min_size_bytes {
242                continue;
243            }
244
245            let days_inactive = record.days_since_access(current_day);
246            let old_stage = record.stage;
247
248            // Determine new stage based on inactivity
249            let new_stage = if days_inactive >= self.policy.expire_after_days {
250                ProxyStage::Expired
251            } else if days_inactive >= self.policy.stale_after_days {
252                ProxyStage::Stale
253            } else if days_inactive >= self.policy.idle_after_days {
254                ProxyStage::Idle
255            } else {
256                ProxyStage::Active
257            };
258
259            // Only advance stage, never go backwards during sweep
260            if new_stage as u8 > old_stage as u8 || old_stage == ProxyStage::Active {
261                match new_stage {
262                    ProxyStage::Idle if old_stage == ProxyStage::Active => {
263                        record.stage = ProxyStage::Idle;
264                        result.newly_idle += 1;
265                    }
266                    ProxyStage::Stale
267                        if old_stage == ProxyStage::Active || old_stage == ProxyStage::Idle =>
268                    {
269                        if self.policy.auto_archive {
270                            record.stage = ProxyStage::Archived;
271                            result.archived += 1;
272                            result.bytes_reclaimed += record.size_bytes;
273                        } else {
274                            record.stage = ProxyStage::Stale;
275                            result.newly_stale += 1;
276                        }
277                    }
278                    ProxyStage::Expired
279                        if old_stage != ProxyStage::Expired && old_stage != ProxyStage::Deleted =>
280                    {
281                        if self.policy.auto_delete {
282                            to_delete.push(record.id.clone());
283                            result.deleted += 1;
284                            result.bytes_reclaimed += record.size_bytes;
285                        } else {
286                            record.stage = ProxyStage::Expired;
287                            result.newly_expired += 1;
288                        }
289                    }
290                    _ => {}
291                }
292            }
293        }
294
295        for id in &to_delete {
296            self.records.remove(id);
297        }
298
299        result
300    }
301
302    /// Get all records in a specific stage.
303    pub fn records_in_stage(&self, stage: ProxyStage) -> Vec<&ProxyRecord> {
304        self.records.values().filter(|r| r.stage == stage).collect()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn make_record(id: &str, size: u64, created_day: u64) -> ProxyRecord {
313        ProxyRecord::new(id, &format!("/proxy/{id}.mp4"), size, created_day)
314    }
315
316    #[test]
317    fn test_proxy_stage_labels() {
318        assert_eq!(ProxyStage::Active.label(), "Active");
319        assert_eq!(ProxyStage::Expired.label(), "Expired");
320        assert_eq!(ProxyStage::Deleted.label(), "Deleted");
321    }
322
323    #[test]
324    fn test_proxy_stage_usable() {
325        assert!(ProxyStage::Active.is_usable());
326        assert!(ProxyStage::Idle.is_usable());
327        assert!(!ProxyStage::Stale.is_usable());
328        assert!(!ProxyStage::Expired.is_usable());
329        assert!(!ProxyStage::Archived.is_usable());
330    }
331
332    #[test]
333    fn test_policy_defaults() {
334        let policy = AgingPolicy::default();
335        assert_eq!(policy.idle_after_days, 7);
336        assert_eq!(policy.stale_after_days, 30);
337        assert_eq!(policy.expire_after_days, 90);
338    }
339
340    #[test]
341    fn test_policy_strict() {
342        let policy = AgingPolicy::strict();
343        assert!(policy.auto_delete);
344        assert!(policy.expire_after_days < AgingPolicy::default().expire_after_days);
345    }
346
347    #[test]
348    fn test_policy_relaxed() {
349        let policy = AgingPolicy::relaxed();
350        assert!(!policy.auto_delete);
351        assert!(policy.expire_after_days > AgingPolicy::default().expire_after_days);
352    }
353
354    #[test]
355    fn test_record_access_reactivates() {
356        let mut rec = make_record("a", 1000, 0);
357        rec.stage = ProxyStage::Stale;
358        rec.record_access(50);
359        assert_eq!(rec.stage, ProxyStage::Active);
360        assert_eq!(rec.last_access_day, 50);
361        assert_eq!(rec.access_count, 1);
362    }
363
364    #[test]
365    fn test_record_days_since_access() {
366        let rec = make_record("a", 1000, 10);
367        assert_eq!(rec.days_since_access(25), 15);
368    }
369
370    #[test]
371    fn test_record_age_days() {
372        let rec = make_record("a", 1000, 10);
373        assert_eq!(rec.age_days(50), 40);
374    }
375
376    #[test]
377    fn test_manager_add_and_get() {
378        let mut mgr = AgingManager::new(AgingPolicy::default());
379        mgr.add_record(make_record("a", 5000, 0));
380        assert_eq!(mgr.record_count(), 1);
381        assert!(mgr.get_record("a").is_some());
382        assert!(mgr.get_record("b").is_none());
383    }
384
385    #[test]
386    fn test_manager_total_size() {
387        let mut mgr = AgingManager::new(AgingPolicy::default());
388        mgr.add_record(make_record("a", 5000, 0));
389        mgr.add_record(make_record("b", 3000, 0));
390        assert_eq!(mgr.total_size_bytes(), 8000);
391    }
392
393    #[test]
394    fn test_sweep_idle_transition() {
395        let mut mgr = AgingManager::new(AgingPolicy::default());
396        mgr.add_record(make_record("a", 5000, 0));
397        // Sweep at day 10 (idle_after_days = 7)
398        let result = mgr.sweep(10);
399        assert_eq!(result.newly_idle, 1);
400        assert_eq!(
401            mgr.get_record("a").expect("should succeed in test").stage,
402            ProxyStage::Idle
403        );
404    }
405
406    #[test]
407    fn test_sweep_auto_archive() {
408        let mut policy = AgingPolicy::default();
409        policy.auto_archive = true;
410        let mut mgr = AgingManager::new(policy);
411        mgr.add_record(make_record("a", 5000, 0));
412        // Sweep at day 35 (stale_after_days = 30)
413        let result = mgr.sweep(35);
414        assert_eq!(result.archived, 1);
415        assert_eq!(
416            mgr.get_record("a").expect("should succeed in test").stage,
417            ProxyStage::Archived
418        );
419    }
420
421    #[test]
422    fn test_sweep_auto_delete() {
423        let policy = AgingPolicy::strict();
424        let mut mgr = AgingManager::new(policy);
425        mgr.add_record(make_record("a", 5000, 0));
426        // Sweep at day 35 (strict expire_after_days = 30)
427        let result = mgr.sweep(35);
428        assert_eq!(result.deleted, 1);
429        assert!(mgr.get_record("a").is_none());
430    }
431
432    #[test]
433    fn test_sweep_skips_small_files() {
434        let mut policy = AgingPolicy::default();
435        policy.min_size_bytes = 10_000;
436        let mut mgr = AgingManager::new(policy);
437        mgr.add_record(make_record("tiny", 500, 0));
438        let result = mgr.sweep(100);
439        // Should not transition tiny files
440        assert_eq!(result.total_transitions(), 0);
441        assert_eq!(
442            mgr.get_record("tiny")
443                .expect("should succeed in test")
444                .stage,
445            ProxyStage::Active
446        );
447    }
448
449    #[test]
450    fn test_records_in_stage() {
451        let mut mgr = AgingManager::new(AgingPolicy::default());
452        mgr.add_record(make_record("a", 5000, 0));
453        mgr.add_record(make_record("b", 5000, 0));
454        let active = mgr.records_in_stage(ProxyStage::Active);
455        assert_eq!(active.len(), 2);
456    }
457
458    #[test]
459    fn test_record_access_through_manager() {
460        let mut mgr = AgingManager::new(AgingPolicy::default());
461        mgr.add_record(make_record("a", 5000, 0));
462        assert!(mgr.record_access("a", 5));
463        assert!(!mgr.record_access("nonexistent", 5));
464        assert_eq!(
465            mgr.get_record("a")
466                .expect("should succeed in test")
467                .access_count,
468            1
469        );
470    }
471}