Skip to main content

ipfrs_storage/
object_lifecycle.rs

1//! Object Lifecycle Manager — Policy-driven object lifecycle management
2//!
3//! Provides TTL expiry, retention rules, and automated tier transitions
4//! for objects stored in IPFRS. Rules are evaluated in priority-descending order
5//! and produce lifecycle actions that are applied atomically to managed objects.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use thiserror::Error;
10
11// ── Error type ───────────────────────────────────────────────────────────────
12
13/// Errors produced by [`ObjectLifecycleManager`].
14#[derive(Debug, Error, Clone, PartialEq, Eq)]
15pub enum LifecycleError {
16    /// Object with the given ID already exists in the registry.
17    #[error("object already exists: {0}")]
18    ObjectAlreadyExists(String),
19
20    /// No object with the given ID found.
21    #[error("object not found: {0}")]
22    ObjectNotFound(String),
23
24    /// Object is in an unexpected state for the requested operation.
25    #[error("invalid state for object {object_id}: current={current}, expected={expected}")]
26    InvalidState {
27        object_id: String,
28        current: String,
29        expected: String,
30    },
31
32    /// Referenced rule name does not exist.
33    #[error("rule not found: {0}")]
34    RuleNotFound(String),
35}
36
37// ── LifecycleState ────────────────────────────────────────────────────────────
38
39/// The current lifecycle state of a managed object.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub enum OlmLifecycleState {
42    /// Object is live and accessible.
43    Active,
44    /// Object will expire at `expires_at` (Unix ms).
45    Expiring { expires_at: u64 },
46    /// Object has been moved to archive storage.
47    Archived,
48    /// Object is scheduled for deletion.
49    MarkedForDeletion,
50    /// Object has been logically deleted; may still linger until purge.
51    Deleted,
52}
53
54impl OlmLifecycleState {
55    /// Short name used for filtering / stats.
56    pub fn name(&self) -> &'static str {
57        match self {
58            OlmLifecycleState::Active => "Active",
59            OlmLifecycleState::Expiring { .. } => "Expiring",
60            OlmLifecycleState::Archived => "Archived",
61            OlmLifecycleState::MarkedForDeletion => "MarkedForDeletion",
62            OlmLifecycleState::Deleted => "Deleted",
63        }
64    }
65}
66
67// ── RetentionRule ─────────────────────────────────────────────────────────────
68
69/// A named rule that governs when and how objects transition or expire.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct OlmRetentionRule {
72    /// Unique human-readable name for this rule.
73    pub name: String,
74
75    /// Object must be at least this many milliseconds old for the rule to match.
76    pub min_age_ms: u64,
77
78    /// Objects older than this (ms) are auto-expired.  `None` = never auto-expire.
79    pub max_age_ms: Option<u64>,
80
81    /// Tier to transition to when the rule fires.  `None` = delete the object.
82    pub transition_to: Option<String>,
83
84    /// Higher priority wins when multiple rules match the same object.
85    pub priority: u32,
86}
87
88// ── ManagedObject ─────────────────────────────────────────────────────────────
89
90/// An object tracked by the lifecycle manager.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ManagedObject {
93    /// Unique identifier.
94    pub object_id: String,
95
96    /// Logical namespace / tenant.
97    pub namespace: String,
98
99    /// Size on disk in bytes.
100    pub size_bytes: u64,
101
102    /// Unix timestamp (ms) when the object was created.
103    pub created_at: u64,
104
105    /// Unix timestamp (ms) of the most recent access.
106    pub last_accessed: u64,
107
108    /// Current lifecycle state.
109    pub state: OlmLifecycleState,
110
111    /// Name of the storage tier this object currently lives on.
112    pub current_tier: String,
113
114    /// Arbitrary user-defined tags.
115    pub tags: HashMap<String, String>,
116
117    /// Name of the retention rule currently applied to this object, if any.
118    pub retention_rule: Option<String>,
119}
120
121// ── LifecycleAction ───────────────────────────────────────────────────────────
122
123/// An action produced by [`ObjectLifecycleManager::apply_rules`].
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub enum OlmLifecycleAction {
126    /// Move object from one tier to another.
127    Transition {
128        object_id: String,
129        from_tier: String,
130        to_tier: String,
131    },
132    /// Permanently delete the object.
133    Delete { object_id: String, reason: String },
134    /// Move object to archive storage.
135    Archive { object_id: String },
136    /// Mark object as expiring at the given timestamp.
137    SetExpiring { object_id: String, expires_at: u64 },
138}
139
140impl OlmLifecycleAction {
141    /// Returns the object ID this action targets.
142    pub fn object_id(&self) -> &str {
143        match self {
144            OlmLifecycleAction::Transition { object_id, .. } => object_id,
145            OlmLifecycleAction::Delete { object_id, .. } => object_id,
146            OlmLifecycleAction::Archive { object_id } => object_id,
147            OlmLifecycleAction::SetExpiring { object_id, .. } => object_id,
148        }
149    }
150}
151
152// ── LifecycleStats ────────────────────────────────────────────────────────────
153
154/// Aggregate statistics for the lifecycle manager.
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct OlmLifecycleStats {
157    pub total_objects: usize,
158    pub active_count: usize,
159    pub expiring_count: usize,
160    pub archived_count: usize,
161    pub marked_for_deletion_count: usize,
162    pub deleted_count: usize,
163    pub total_active_bytes: u64,
164    pub rules_count: usize,
165}
166
167// ── ObjectLifecycleManager ───────────────────────────────────────────────────
168
169/// Policy-driven object lifecycle manager.
170///
171/// Objects are registered and then evaluated against a set of [`OlmRetentionRule`]s
172/// every time [`apply_rules`](Self::apply_rules) is called.  The manager updates
173/// object states internally and returns the list of actions that must be executed
174/// by the caller (e.g., actually moving bytes between tiers or removing data).
175pub struct ObjectLifecycleManager {
176    objects: HashMap<String, ManagedObject>,
177    rules: Vec<OlmRetentionRule>,
178    default_ttl_ms: Option<u64>,
179    /// Insertion-order counter used to break priority ties deterministically.
180    rule_insertion_order: Vec<String>,
181}
182
183impl ObjectLifecycleManager {
184    // ── Construction ──────────────────────────────────────────────────────────
185
186    /// Create a new manager with an optional default TTL (milliseconds).
187    ///
188    /// When `default_ttl_ms` is `Some(n)`, objects that match no rule and are
189    /// older than `n` ms are deleted automatically.
190    pub fn new(default_ttl_ms: Option<u64>) -> Self {
191        Self {
192            objects: HashMap::new(),
193            rules: Vec::new(),
194            default_ttl_ms,
195            rule_insertion_order: Vec::new(),
196        }
197    }
198
199    // ── Rule management ───────────────────────────────────────────────────────
200
201    /// Append a rule.  Rules are evaluated in **priority-descending** order;
202    /// equal-priority rules are evaluated in insertion order (earlier first).
203    pub fn add_rule(&mut self, rule: OlmRetentionRule) {
204        self.rule_insertion_order.push(rule.name.clone());
205        self.rules.push(rule);
206    }
207
208    // ── Object registration ───────────────────────────────────────────────────
209
210    /// Register a new object.  Returns [`LifecycleError::ObjectAlreadyExists`]
211    /// if an object with the same ID already exists.
212    pub fn register_object(&mut self, object: ManagedObject) -> Result<(), LifecycleError> {
213        if self.objects.contains_key(&object.object_id) {
214            return Err(LifecycleError::ObjectAlreadyExists(
215                object.object_id.clone(),
216            ));
217        }
218        self.objects.insert(object.object_id.clone(), object);
219        Ok(())
220    }
221
222    /// Remove and return a registered object.
223    pub fn unregister_object(&mut self, object_id: &str) -> Result<ManagedObject, LifecycleError> {
224        self.objects
225            .remove(object_id)
226            .ok_or_else(|| LifecycleError::ObjectNotFound(object_id.to_owned()))
227    }
228
229    /// Update `last_accessed` to `now`.
230    ///
231    /// Returns [`LifecycleError::InvalidState`] when the object is in
232    /// `Deleted` state (deleted objects cannot be accessed).
233    pub fn access_object(&mut self, object_id: &str, now: u64) -> Result<(), LifecycleError> {
234        let obj = self
235            .objects
236            .get_mut(object_id)
237            .ok_or_else(|| LifecycleError::ObjectNotFound(object_id.to_owned()))?;
238
239        if obj.state == OlmLifecycleState::Deleted {
240            return Err(LifecycleError::InvalidState {
241                object_id: object_id.to_owned(),
242                current: "Deleted".to_owned(),
243                expected: "not Deleted".to_owned(),
244            });
245        }
246
247        obj.last_accessed = now;
248        Ok(())
249    }
250
251    // ── Core rule evaluation ─────────────────────────────────────────────────
252
253    /// Evaluate all active objects against registered rules and the default TTL.
254    ///
255    /// Returns a list of actions.  The manager **also updates object states
256    /// internally**, so callers do not need to call `execute_action` to keep
257    /// the manager consistent — but they should use the returned actions to
258    /// perform the actual data movement / deletion.
259    pub fn apply_rules(&mut self, now: u64) -> Vec<OlmLifecycleAction> {
260        // Build a sorted rule index: higher priority first, then insertion order.
261        let sorted_indices = self.sorted_rule_indices();
262
263        let mut actions: Vec<OlmLifecycleAction> = Vec::new();
264
265        // Collect IDs first to avoid borrow conflicts.
266        let ids: Vec<String> = self.objects.keys().cloned().collect();
267
268        for id in ids {
269            let obj = match self.objects.get(&id) {
270                Some(o) => o,
271                None => continue,
272            };
273
274            // Skip already-deleted objects.
275            if obj.state == OlmLifecycleState::Deleted {
276                continue;
277            }
278
279            let age_ms = now.saturating_sub(obj.created_at);
280            let mut matched_rule: Option<usize> = None; // index into self.rules
281
282            // Find highest-priority matching rule (age >= min_age_ms).
283            for &rule_idx in &sorted_indices {
284                let rule = &self.rules[rule_idx];
285                if age_ms >= rule.min_age_ms {
286                    matched_rule = Some(rule_idx);
287                    break; // sorted by priority, first match wins
288                }
289            }
290
291            if let Some(rule_idx) = matched_rule {
292                let rule = self.rules[rule_idx].clone();
293
294                // Only fire when max_age_ms is set and the object is old enough.
295                if let Some(max_age) = rule.max_age_ms {
296                    if age_ms >= max_age {
297                        let action = self.build_rule_action(id.clone(), &rule);
298                        self.apply_action_to_object(&id, &action);
299                        actions.push(action);
300                        continue;
301                    }
302                }
303
304                // Rule matched but max_age not reached — record the rule association.
305                if let Some(obj_mut) = self.objects.get_mut(&id) {
306                    obj_mut.retention_rule = Some(rule.name.clone());
307                }
308            } else {
309                // No rule matched; check default TTL.
310                if let Some(ttl) = self.default_ttl_ms {
311                    if age_ms >= ttl {
312                        let action = OlmLifecycleAction::Delete {
313                            object_id: id.clone(),
314                            reason: "default_ttl_expired".to_owned(),
315                        };
316                        self.apply_action_to_object(&id, &action);
317                        actions.push(action);
318                    }
319                }
320            }
321        }
322
323        actions
324    }
325
326    /// Apply the state change for a single action.
327    ///
328    /// This is idempotent for re-applying the same action and can be called
329    /// after the fact if needed.  Returns an error if the referenced object
330    /// does not exist.
331    pub fn execute_action(
332        &mut self,
333        action: &OlmLifecycleAction,
334        _now: u64,
335    ) -> Result<(), LifecycleError> {
336        let id = action.object_id();
337        if !self.objects.contains_key(id) {
338            return Err(LifecycleError::ObjectNotFound(id.to_owned()));
339        }
340        self.apply_action_to_object(id, action);
341        Ok(())
342    }
343
344    // ── Query helpers ─────────────────────────────────────────────────────────
345
346    /// Return all objects whose state name matches `state_name`.
347    ///
348    /// Valid names: `"Active"`, `"Expiring"`, `"Archived"`, `"MarkedForDeletion"`,
349    /// `"Deleted"`.
350    pub fn objects_in_state(&self, state_name: &str) -> Vec<&ManagedObject> {
351        self.objects
352            .values()
353            .filter(|o| o.state.name() == state_name)
354            .collect()
355    }
356
357    /// Return all objects in `Expiring` state whose `expires_at` ≤ `timestamp`.
358    pub fn objects_expiring_before(&self, timestamp: u64) -> Vec<&ManagedObject> {
359        self.objects
360            .values()
361            .filter(|o| {
362                matches!(
363                    &o.state,
364                    OlmLifecycleState::Expiring { expires_at }
365                    if *expires_at <= timestamp
366                )
367            })
368            .collect()
369    }
370
371    /// Return all objects on `tier`, sorted by `created_at` ascending.
372    pub fn objects_by_tier(&self, tier: &str) -> Vec<&ManagedObject> {
373        let mut result: Vec<&ManagedObject> = self
374            .objects
375            .values()
376            .filter(|o| o.current_tier == tier)
377            .collect();
378        result.sort_by_key(|o| o.created_at);
379        result
380    }
381
382    /// Remove logically deleted objects that were created more than 1 hour ago.
383    ///
384    /// Returns the number of objects removed.
385    pub fn purge_deleted(&mut self, now: u64) -> usize {
386        const ONE_HOUR_MS: u64 = 3_600_000;
387        let threshold = now.saturating_sub(ONE_HOUR_MS);
388
389        let to_remove: Vec<String> = self
390            .objects
391            .iter()
392            .filter(|(_, o)| o.state == OlmLifecycleState::Deleted && o.created_at <= threshold)
393            .map(|(id, _)| id.clone())
394            .collect();
395
396        let count = to_remove.len();
397        for id in to_remove {
398            self.objects.remove(&id);
399        }
400        count
401    }
402
403    /// Sum of `size_bytes` for all `Active` and `Expiring` objects.
404    pub fn total_active_bytes(&self) -> u64 {
405        self.objects
406            .values()
407            .filter(|o| {
408                matches!(
409                    o.state,
410                    OlmLifecycleState::Active | OlmLifecycleState::Expiring { .. }
411                )
412            })
413            .map(|o| o.size_bytes)
414            .sum()
415    }
416
417    /// Aggregate statistics snapshot.
418    pub fn stats(&self) -> OlmLifecycleStats {
419        let mut s = OlmLifecycleStats {
420            total_objects: self.objects.len(),
421            rules_count: self.rules.len(),
422            ..Default::default()
423        };
424
425        for obj in self.objects.values() {
426            match &obj.state {
427                OlmLifecycleState::Active => {
428                    s.active_count += 1;
429                    s.total_active_bytes += obj.size_bytes;
430                }
431                OlmLifecycleState::Expiring { .. } => {
432                    s.expiring_count += 1;
433                    s.total_active_bytes += obj.size_bytes;
434                }
435                OlmLifecycleState::Archived => s.archived_count += 1,
436                OlmLifecycleState::MarkedForDeletion => s.marked_for_deletion_count += 1,
437                OlmLifecycleState::Deleted => s.deleted_count += 1,
438            }
439        }
440
441        s
442    }
443
444    // ── Private helpers ───────────────────────────────────────────────────────
445
446    /// Returns rule indices sorted: higher priority first, then by insertion order.
447    fn sorted_rule_indices(&self) -> Vec<usize> {
448        let mut indices: Vec<usize> = (0..self.rules.len()).collect();
449        indices.sort_by(|&a, &b| {
450            let pa = self.rules[a].priority;
451            let pb = self.rules[b].priority;
452            if pa != pb {
453                return pb.cmp(&pa); // descending priority
454            }
455            // Equal priority → earlier insertion first.
456            let ia = self
457                .rule_insertion_order
458                .iter()
459                .position(|n| n == &self.rules[a].name)
460                .unwrap_or(usize::MAX);
461            let ib = self
462                .rule_insertion_order
463                .iter()
464                .position(|n| n == &self.rules[b].name)
465                .unwrap_or(usize::MAX);
466            ia.cmp(&ib)
467        });
468        indices
469    }
470
471    /// Build the appropriate action for a rule firing on an object.
472    fn build_rule_action(&self, object_id: String, rule: &OlmRetentionRule) -> OlmLifecycleAction {
473        match &rule.transition_to {
474            Some(to_tier) => {
475                let from_tier = self
476                    .objects
477                    .get(&object_id)
478                    .map(|o| o.current_tier.clone())
479                    .unwrap_or_default();
480                OlmLifecycleAction::Transition {
481                    object_id,
482                    from_tier,
483                    to_tier: to_tier.clone(),
484                }
485            }
486            None => OlmLifecycleAction::Delete {
487                object_id,
488                reason: format!("rule:{}", rule.name),
489            },
490        }
491    }
492
493    /// Apply the state change implied by an action directly to the object map.
494    fn apply_action_to_object(&mut self, object_id: &str, action: &OlmLifecycleAction) {
495        let obj = match self.objects.get_mut(object_id) {
496            Some(o) => o,
497            None => return,
498        };
499
500        match action {
501            OlmLifecycleAction::Transition { to_tier, .. } => {
502                obj.current_tier = to_tier.clone();
503                // Keep state Active unless it was already something else.
504                if obj.state == OlmLifecycleState::Active {
505                    // state remains Active
506                }
507            }
508            OlmLifecycleAction::Delete { .. } => {
509                obj.state = OlmLifecycleState::Deleted;
510            }
511            OlmLifecycleAction::Archive { .. } => {
512                obj.state = OlmLifecycleState::Archived;
513            }
514            OlmLifecycleAction::SetExpiring { expires_at, .. } => {
515                obj.state = OlmLifecycleState::Expiring {
516                    expires_at: *expires_at,
517                };
518            }
519        }
520    }
521}
522
523// ── Tests ─────────────────────────────────────────────────────────────────────
524
525#[cfg(test)]
526mod tests {
527    use super::{
528        LifecycleError, ManagedObject, ObjectLifecycleManager, OlmLifecycleAction,
529        OlmLifecycleState, OlmRetentionRule,
530    };
531    use std::collections::HashMap;
532
533    // ── Fixtures ──────────────────────────────────────────────────────────────
534
535    fn obj(id: &str, created_at: u64) -> ManagedObject {
536        ManagedObject {
537            object_id: id.to_owned(),
538            namespace: "test".to_owned(),
539            size_bytes: 1024,
540            created_at,
541            last_accessed: created_at,
542            state: OlmLifecycleState::Active,
543            current_tier: "hot".to_owned(),
544            tags: HashMap::new(),
545            retention_rule: None,
546        }
547    }
548
549    fn rule(
550        name: &str,
551        min_age_ms: u64,
552        max_age_ms: Option<u64>,
553        transition_to: Option<&str>,
554        priority: u32,
555    ) -> OlmRetentionRule {
556        OlmRetentionRule {
557            name: name.to_owned(),
558            min_age_ms,
559            max_age_ms,
560            transition_to: transition_to.map(|s| s.to_owned()),
561            priority,
562        }
563    }
564
565    // ── new / basic construction ──────────────────────────────────────────────
566
567    #[test]
568    fn test_new_no_ttl() {
569        let mgr = ObjectLifecycleManager::new(None);
570        let s = mgr.stats();
571        assert_eq!(s.total_objects, 0);
572        assert_eq!(s.rules_count, 0);
573    }
574
575    #[test]
576    fn test_new_with_ttl() {
577        let mgr = ObjectLifecycleManager::new(Some(60_000));
578        let s = mgr.stats();
579        assert_eq!(s.total_objects, 0);
580    }
581
582    // ── register / unregister ─────────────────────────────────────────────────
583
584    #[test]
585    fn test_register_object_ok() {
586        let mut mgr = ObjectLifecycleManager::new(None);
587        assert!(mgr.register_object(obj("a", 0)).is_ok());
588        assert_eq!(mgr.stats().total_objects, 1);
589    }
590
591    #[test]
592    fn test_register_duplicate_error() {
593        let mut mgr = ObjectLifecycleManager::new(None);
594        mgr.register_object(obj("a", 0)).unwrap();
595        let err = mgr.register_object(obj("a", 0)).unwrap_err();
596        assert!(matches!(err, LifecycleError::ObjectAlreadyExists(id) if id == "a"));
597    }
598
599    #[test]
600    fn test_unregister_ok() {
601        let mut mgr = ObjectLifecycleManager::new(None);
602        mgr.register_object(obj("a", 0)).unwrap();
603        let removed = mgr.unregister_object("a").unwrap();
604        assert_eq!(removed.object_id, "a");
605        assert_eq!(mgr.stats().total_objects, 0);
606    }
607
608    #[test]
609    fn test_unregister_not_found() {
610        let mut mgr = ObjectLifecycleManager::new(None);
611        let err = mgr.unregister_object("missing").unwrap_err();
612        assert!(matches!(err, LifecycleError::ObjectNotFound(_)));
613    }
614
615    // ── access_object ─────────────────────────────────────────────────────────
616
617    #[test]
618    fn test_access_updates_last_accessed() {
619        let mut mgr = ObjectLifecycleManager::new(None);
620        mgr.register_object(obj("a", 100)).unwrap();
621        mgr.access_object("a", 999).unwrap();
622        let o = mgr.unregister_object("a").unwrap();
623        assert_eq!(o.last_accessed, 999);
624    }
625
626    #[test]
627    fn test_access_deleted_returns_error() {
628        let mut mgr = ObjectLifecycleManager::new(None);
629        let mut o = obj("a", 0);
630        o.state = OlmLifecycleState::Deleted;
631        mgr.register_object(o).unwrap();
632        let err = mgr.access_object("a", 1).unwrap_err();
633        assert!(matches!(err, LifecycleError::InvalidState { .. }));
634    }
635
636    #[test]
637    fn test_access_not_found() {
638        let mut mgr = ObjectLifecycleManager::new(None);
639        let err = mgr.access_object("nope", 1).unwrap_err();
640        assert!(matches!(err, LifecycleError::ObjectNotFound(_)));
641    }
642
643    // ── apply_rules — default TTL ─────────────────────────────────────────────
644
645    #[test]
646    fn test_default_ttl_fires_delete() {
647        let mut mgr = ObjectLifecycleManager::new(Some(1000));
648        mgr.register_object(obj("a", 0)).unwrap();
649        let actions = mgr.apply_rules(2000);
650        assert_eq!(actions.len(), 1);
651        assert!(matches!(
652            &actions[0],
653            OlmLifecycleAction::Delete { object_id, reason }
654            if object_id == "a" && reason == "default_ttl_expired"
655        ));
656    }
657
658    #[test]
659    fn test_default_ttl_not_fired_when_young() {
660        let mut mgr = ObjectLifecycleManager::new(Some(5000));
661        mgr.register_object(obj("a", 0)).unwrap();
662        let actions = mgr.apply_rules(100);
663        assert!(actions.is_empty());
664    }
665
666    #[test]
667    fn test_no_default_ttl_no_action() {
668        let mut mgr = ObjectLifecycleManager::new(None);
669        mgr.register_object(obj("a", 0)).unwrap();
670        let actions = mgr.apply_rules(999_999);
671        assert!(actions.is_empty());
672    }
673
674    // ── apply_rules — rule-based transitions ──────────────────────────────────
675
676    #[test]
677    fn test_rule_transition_fires() {
678        let mut mgr = ObjectLifecycleManager::new(None);
679        mgr.add_rule(rule("to_warm", 100, Some(500), Some("warm"), 10));
680        mgr.register_object(obj("a", 0)).unwrap();
681        let actions = mgr.apply_rules(600);
682        assert_eq!(actions.len(), 1);
683        assert!(matches!(
684            &actions[0],
685            OlmLifecycleAction::Transition { object_id, to_tier, .. }
686            if object_id == "a" && to_tier == "warm"
687        ));
688    }
689
690    #[test]
691    fn test_rule_delete_fires_when_no_transition() {
692        let mut mgr = ObjectLifecycleManager::new(None);
693        mgr.add_rule(rule("delete_old", 0, Some(100), None, 5));
694        mgr.register_object(obj("a", 0)).unwrap();
695        let actions = mgr.apply_rules(200);
696        assert_eq!(actions.len(), 1);
697        assert!(matches!(
698            &actions[0],
699            OlmLifecycleAction::Delete { object_id, .. }
700            if object_id == "a"
701        ));
702    }
703
704    #[test]
705    fn test_rule_not_fired_before_max_age() {
706        let mut mgr = ObjectLifecycleManager::new(None);
707        mgr.add_rule(rule("to_cold", 0, Some(10_000), Some("cold"), 1));
708        mgr.register_object(obj("a", 0)).unwrap();
709        let actions = mgr.apply_rules(5_000);
710        assert!(actions.is_empty());
711    }
712
713    #[test]
714    fn test_rule_not_matched_before_min_age() {
715        let mut mgr = ObjectLifecycleManager::new(None);
716        mgr.add_rule(rule("old_only", 10_000, Some(20_000), Some("cold"), 1));
717        mgr.register_object(obj("a", 0)).unwrap();
718        // age = 5000 < min_age_ms = 10000 → rule doesn't match at all
719        let actions = mgr.apply_rules(5_000);
720        assert!(actions.is_empty());
721    }
722
723    // ── apply_rules — priority ordering ───────────────────────────────────────
724
725    #[test]
726    fn test_higher_priority_rule_wins() {
727        let mut mgr = ObjectLifecycleManager::new(None);
728        // Lower priority rule: delete
729        mgr.add_rule(rule("delete_low", 0, Some(100), None, 1));
730        // Higher priority rule: transition
731        mgr.add_rule(rule("transit_high", 0, Some(100), Some("warm"), 10));
732        mgr.register_object(obj("a", 0)).unwrap();
733        let actions = mgr.apply_rules(200);
734        assert_eq!(actions.len(), 1);
735        assert!(matches!(
736            &actions[0],
737            OlmLifecycleAction::Transition { to_tier, .. }
738            if to_tier == "warm"
739        ));
740    }
741
742    #[test]
743    fn test_equal_priority_insertion_order() {
744        let mut mgr = ObjectLifecycleManager::new(None);
745        // Both same priority — first inserted wins
746        mgr.add_rule(rule("first", 0, Some(100), Some("warm"), 5));
747        mgr.add_rule(rule("second", 0, Some(100), Some("cold"), 5));
748        mgr.register_object(obj("a", 0)).unwrap();
749        let actions = mgr.apply_rules(200);
750        assert_eq!(actions.len(), 1);
751        assert!(matches!(
752            &actions[0],
753            OlmLifecycleAction::Transition { to_tier, .. }
754            if to_tier == "warm"
755        ));
756    }
757
758    // ── apply_rules — state updates ───────────────────────────────────────────
759
760    #[test]
761    fn test_apply_rules_marks_object_deleted() {
762        let mut mgr = ObjectLifecycleManager::new(Some(1000));
763        mgr.register_object(obj("a", 0)).unwrap();
764        mgr.apply_rules(2000);
765        let objs = mgr.objects_in_state("Deleted");
766        assert_eq!(objs.len(), 1);
767        assert_eq!(objs[0].object_id, "a");
768    }
769
770    #[test]
771    fn test_deleted_object_skipped_on_second_run() {
772        let mut mgr = ObjectLifecycleManager::new(Some(500));
773        mgr.register_object(obj("a", 0)).unwrap();
774        let a1 = mgr.apply_rules(1000);
775        let a2 = mgr.apply_rules(2000);
776        assert_eq!(a1.len(), 1);
777        assert!(a2.is_empty(), "Deleted objects must be skipped");
778    }
779
780    // ── execute_action ────────────────────────────────────────────────────────
781
782    #[test]
783    fn test_execute_action_delete() {
784        let mut mgr = ObjectLifecycleManager::new(None);
785        mgr.register_object(obj("a", 0)).unwrap();
786        let action = OlmLifecycleAction::Delete {
787            object_id: "a".to_owned(),
788            reason: "manual".to_owned(),
789        };
790        mgr.execute_action(&action, 100).unwrap();
791        assert_eq!(mgr.objects_in_state("Deleted").len(), 1);
792    }
793
794    #[test]
795    fn test_execute_action_archive() {
796        let mut mgr = ObjectLifecycleManager::new(None);
797        mgr.register_object(obj("a", 0)).unwrap();
798        let action = OlmLifecycleAction::Archive {
799            object_id: "a".to_owned(),
800        };
801        mgr.execute_action(&action, 100).unwrap();
802        assert_eq!(mgr.objects_in_state("Archived").len(), 1);
803    }
804
805    #[test]
806    fn test_execute_action_set_expiring() {
807        let mut mgr = ObjectLifecycleManager::new(None);
808        mgr.register_object(obj("a", 0)).unwrap();
809        let action = OlmLifecycleAction::SetExpiring {
810            object_id: "a".to_owned(),
811            expires_at: 5000,
812        };
813        mgr.execute_action(&action, 100).unwrap();
814        assert_eq!(mgr.objects_in_state("Expiring").len(), 1);
815    }
816
817    #[test]
818    fn test_execute_action_transition_updates_tier() {
819        let mut mgr = ObjectLifecycleManager::new(None);
820        mgr.register_object(obj("a", 0)).unwrap();
821        let action = OlmLifecycleAction::Transition {
822            object_id: "a".to_owned(),
823            from_tier: "hot".to_owned(),
824            to_tier: "cold".to_owned(),
825        };
826        mgr.execute_action(&action, 100).unwrap();
827        let tiers = mgr.objects_by_tier("cold");
828        assert_eq!(tiers.len(), 1);
829    }
830
831    #[test]
832    fn test_execute_action_not_found() {
833        let mut mgr = ObjectLifecycleManager::new(None);
834        let action = OlmLifecycleAction::Delete {
835            object_id: "ghost".to_owned(),
836            reason: "test".to_owned(),
837        };
838        let err = mgr.execute_action(&action, 0).unwrap_err();
839        assert!(matches!(err, LifecycleError::ObjectNotFound(_)));
840    }
841
842    // ── objects_in_state ──────────────────────────────────────────────────────
843
844    #[test]
845    fn test_objects_in_state_active() {
846        let mut mgr = ObjectLifecycleManager::new(None);
847        mgr.register_object(obj("a", 0)).unwrap();
848        mgr.register_object(obj("b", 0)).unwrap();
849        assert_eq!(mgr.objects_in_state("Active").len(), 2);
850        assert_eq!(mgr.objects_in_state("Deleted").len(), 0);
851    }
852
853    #[test]
854    fn test_objects_in_state_unknown_returns_empty() {
855        let mut mgr = ObjectLifecycleManager::new(None);
856        mgr.register_object(obj("a", 0)).unwrap();
857        assert!(mgr.objects_in_state("NonExistent").is_empty());
858    }
859
860    // ── objects_expiring_before ───────────────────────────────────────────────
861
862    #[test]
863    fn test_objects_expiring_before() {
864        let mut mgr = ObjectLifecycleManager::new(None);
865        mgr.register_object(obj("a", 0)).unwrap();
866        mgr.register_object(obj("b", 0)).unwrap();
867        let a1 = OlmLifecycleAction::SetExpiring {
868            object_id: "a".to_owned(),
869            expires_at: 100,
870        };
871        let a2 = OlmLifecycleAction::SetExpiring {
872            object_id: "b".to_owned(),
873            expires_at: 9999,
874        };
875        mgr.execute_action(&a1, 0).unwrap();
876        mgr.execute_action(&a2, 0).unwrap();
877
878        let expiring = mgr.objects_expiring_before(500);
879        assert_eq!(expiring.len(), 1);
880        assert_eq!(expiring[0].object_id, "a");
881    }
882
883    #[test]
884    fn test_objects_expiring_at_exact_boundary() {
885        let mut mgr = ObjectLifecycleManager::new(None);
886        mgr.register_object(obj("a", 0)).unwrap();
887        let action = OlmLifecycleAction::SetExpiring {
888            object_id: "a".to_owned(),
889            expires_at: 100,
890        };
891        mgr.execute_action(&action, 0).unwrap();
892        // Boundary: expires_at == timestamp → should be included (≤)
893        assert_eq!(mgr.objects_expiring_before(100).len(), 1);
894        assert_eq!(mgr.objects_expiring_before(99).len(), 0);
895    }
896
897    // ── objects_by_tier ───────────────────────────────────────────────────────
898
899    #[test]
900    fn test_objects_by_tier_sorted_by_created_at() {
901        let mut mgr = ObjectLifecycleManager::new(None);
902        mgr.register_object(obj("c", 300)).unwrap();
903        mgr.register_object(obj("a", 100)).unwrap();
904        mgr.register_object(obj("b", 200)).unwrap();
905        let result = mgr.objects_by_tier("hot");
906        assert_eq!(result.len(), 3);
907        assert_eq!(result[0].object_id, "a");
908        assert_eq!(result[1].object_id, "b");
909        assert_eq!(result[2].object_id, "c");
910    }
911
912    #[test]
913    fn test_objects_by_tier_filter_by_tier_name() {
914        let mut mgr = ObjectLifecycleManager::new(None);
915        let mut warm_obj = obj("w", 0);
916        warm_obj.current_tier = "warm".to_owned();
917        mgr.register_object(obj("h", 0)).unwrap();
918        mgr.register_object(warm_obj).unwrap();
919        assert_eq!(mgr.objects_by_tier("hot").len(), 1);
920        assert_eq!(mgr.objects_by_tier("warm").len(), 1);
921        assert_eq!(mgr.objects_by_tier("cold").len(), 0);
922    }
923
924    // ── purge_deleted ─────────────────────────────────────────────────────────
925
926    #[test]
927    fn test_purge_deleted_removes_old_deleted_objects() {
928        let mut mgr = ObjectLifecycleManager::new(None);
929        let mut d = obj("a", 0); // created_at=0; with now=4_000_000, age > 1 hour
930        d.state = OlmLifecycleState::Deleted;
931        mgr.register_object(d).unwrap();
932        let count = mgr.purge_deleted(4_000_000);
933        assert_eq!(count, 1);
934        assert_eq!(mgr.stats().total_objects, 0);
935    }
936
937    #[test]
938    fn test_purge_deleted_keeps_recent_deleted_objects() {
939        let mut mgr = ObjectLifecycleManager::new(None);
940        let now = 3_600_000_u64; // exactly 1 hour
941        let mut d = obj("a", now - 100); // created 100ms ago → not old enough
942        d.state = OlmLifecycleState::Deleted;
943        mgr.register_object(d).unwrap();
944        let count = mgr.purge_deleted(now);
945        assert_eq!(count, 0);
946        assert_eq!(mgr.stats().total_objects, 1);
947    }
948
949    #[test]
950    fn test_purge_deleted_ignores_active_objects() {
951        let mut mgr = ObjectLifecycleManager::new(None);
952        mgr.register_object(obj("a", 0)).unwrap(); // Active, old
953        let count = mgr.purge_deleted(9_999_999);
954        assert_eq!(count, 0);
955        assert_eq!(mgr.stats().total_objects, 1);
956    }
957
958    // ── total_active_bytes ────────────────────────────────────────────────────
959
960    #[test]
961    fn test_total_active_bytes_sums_active_and_expiring() {
962        let mut mgr = ObjectLifecycleManager::new(None);
963        let mut o1 = obj("a", 0);
964        o1.size_bytes = 500;
965        let mut o2 = obj("b", 0);
966        o2.size_bytes = 300;
967        o2.state = OlmLifecycleState::Expiring { expires_at: 9999 };
968        let mut o3 = obj("c", 0);
969        o3.size_bytes = 200;
970        o3.state = OlmLifecycleState::Archived;
971        mgr.register_object(o1).unwrap();
972        mgr.register_object(o2).unwrap();
973        mgr.register_object(o3).unwrap();
974        assert_eq!(mgr.total_active_bytes(), 800);
975    }
976
977    #[test]
978    fn test_total_active_bytes_excludes_deleted() {
979        let mut mgr = ObjectLifecycleManager::new(None);
980        let mut o = obj("a", 0);
981        o.size_bytes = 1000;
982        o.state = OlmLifecycleState::Deleted;
983        mgr.register_object(o).unwrap();
984        assert_eq!(mgr.total_active_bytes(), 0);
985    }
986
987    // ── stats ─────────────────────────────────────────────────────────────────
988
989    #[test]
990    fn test_stats_counts_all_states() {
991        let mut mgr = ObjectLifecycleManager::new(None);
992        mgr.register_object(obj("active", 0)).unwrap();
993        let mut exp = obj("exp", 0);
994        exp.state = OlmLifecycleState::Expiring { expires_at: 1 };
995        mgr.register_object(exp).unwrap();
996        let mut arch = obj("arch", 0);
997        arch.state = OlmLifecycleState::Archived;
998        mgr.register_object(arch).unwrap();
999        let mut mfd = obj("mfd", 0);
1000        mfd.state = OlmLifecycleState::MarkedForDeletion;
1001        mgr.register_object(mfd).unwrap();
1002        let mut del = obj("del", 0);
1003        del.state = OlmLifecycleState::Deleted;
1004        mgr.register_object(del).unwrap();
1005
1006        let s = mgr.stats();
1007        assert_eq!(s.total_objects, 5);
1008        assert_eq!(s.active_count, 1);
1009        assert_eq!(s.expiring_count, 1);
1010        assert_eq!(s.archived_count, 1);
1011        assert_eq!(s.marked_for_deletion_count, 1);
1012        assert_eq!(s.deleted_count, 1);
1013    }
1014
1015    #[test]
1016    fn test_stats_rules_count() {
1017        let mut mgr = ObjectLifecycleManager::new(None);
1018        mgr.add_rule(rule("r1", 0, None, None, 1));
1019        mgr.add_rule(rule("r2", 0, None, None, 2));
1020        assert_eq!(mgr.stats().rules_count, 2);
1021    }
1022
1023    // ── add_rule (no max_age, no firing) ──────────────────────────────────────
1024
1025    #[test]
1026    fn test_rule_without_max_age_does_not_fire() {
1027        let mut mgr = ObjectLifecycleManager::new(None);
1028        // No max_age → rule never fires (just records the rule association)
1029        mgr.add_rule(rule("forever", 0, None, Some("warm"), 1));
1030        mgr.register_object(obj("a", 0)).unwrap();
1031        let actions = mgr.apply_rules(999_999_999);
1032        assert!(actions.is_empty());
1033    }
1034
1035    // ── multiple objects ──────────────────────────────────────────────────────
1036
1037    #[test]
1038    fn test_multiple_objects_independent_rules() {
1039        let mut mgr = ObjectLifecycleManager::new(Some(1000));
1040        mgr.register_object(obj("young", 500)).unwrap(); // age at now=1000 → 500ms
1041        mgr.register_object(obj("old", 0)).unwrap(); // age at now=1000 → 1000ms
1042        let actions = mgr.apply_rules(1000);
1043        // Only "old" should be deleted (age == ttl)
1044        assert_eq!(actions.len(), 1);
1045        assert!(matches!(
1046            &actions[0],
1047            OlmLifecycleAction::Delete { object_id, .. }
1048            if object_id == "old"
1049        ));
1050    }
1051
1052    #[test]
1053    fn test_apply_rules_multiple_objects_multiple_actions() {
1054        let mut mgr = ObjectLifecycleManager::new(None);
1055        mgr.add_rule(rule("to_warm", 0, Some(100), Some("warm"), 1));
1056        for i in 0..5_u64 {
1057            mgr.register_object(obj(&format!("o{i}"), 0)).unwrap();
1058        }
1059        let actions = mgr.apply_rules(200);
1060        assert_eq!(actions.len(), 5);
1061        for action in &actions {
1062            assert!(
1063                matches!(action, OlmLifecycleAction::Transition { to_tier, .. } if to_tier == "warm")
1064            );
1065        }
1066    }
1067
1068    // ── LifecycleState::name ──────────────────────────────────────────────────
1069
1070    #[test]
1071    fn test_state_name_variants() {
1072        assert_eq!(OlmLifecycleState::Active.name(), "Active");
1073        assert_eq!(
1074            OlmLifecycleState::Expiring { expires_at: 0 }.name(),
1075            "Expiring"
1076        );
1077        assert_eq!(OlmLifecycleState::Archived.name(), "Archived");
1078        assert_eq!(
1079            OlmLifecycleState::MarkedForDeletion.name(),
1080            "MarkedForDeletion"
1081        );
1082        assert_eq!(OlmLifecycleState::Deleted.name(), "Deleted");
1083    }
1084
1085    // ── OlmLifecycleAction::object_id ────────────────────────────────────────
1086
1087    #[test]
1088    fn test_action_object_id() {
1089        let t = OlmLifecycleAction::Transition {
1090            object_id: "x".to_owned(),
1091            from_tier: "a".to_owned(),
1092            to_tier: "b".to_owned(),
1093        };
1094        assert_eq!(t.object_id(), "x");
1095
1096        let d = OlmLifecycleAction::Delete {
1097            object_id: "y".to_owned(),
1098            reason: "r".to_owned(),
1099        };
1100        assert_eq!(d.object_id(), "y");
1101
1102        let ar = OlmLifecycleAction::Archive {
1103            object_id: "z".to_owned(),
1104        };
1105        assert_eq!(ar.object_id(), "z");
1106
1107        let se = OlmLifecycleAction::SetExpiring {
1108            object_id: "w".to_owned(),
1109            expires_at: 0,
1110        };
1111        assert_eq!(se.object_id(), "w");
1112    }
1113
1114    // ── LifecycleError variants ───────────────────────────────────────────────
1115
1116    #[test]
1117    fn test_error_display_already_exists() {
1118        let e = LifecycleError::ObjectAlreadyExists("abc".to_owned());
1119        assert!(e.to_string().contains("abc"));
1120    }
1121
1122    #[test]
1123    fn test_error_display_not_found() {
1124        let e = LifecycleError::ObjectNotFound("xyz".to_owned());
1125        assert!(e.to_string().contains("xyz"));
1126    }
1127
1128    #[test]
1129    fn test_error_display_invalid_state() {
1130        let e = LifecycleError::InvalidState {
1131            object_id: "o1".to_owned(),
1132            current: "Deleted".to_owned(),
1133            expected: "Active".to_owned(),
1134        };
1135        let s = e.to_string();
1136        assert!(s.contains("o1"));
1137        assert!(s.contains("Deleted"));
1138        assert!(s.contains("Active"));
1139    }
1140
1141    #[test]
1142    fn test_error_display_rule_not_found() {
1143        let e = LifecycleError::RuleNotFound("r99".to_owned());
1144        assert!(e.to_string().contains("r99"));
1145    }
1146
1147    // ── purge_deleted — boundary at exactly 1 hour ────────────────────────────
1148
1149    #[test]
1150    fn test_purge_deleted_exactly_one_hour_boundary() {
1151        let mut mgr = ObjectLifecycleManager::new(None);
1152        let one_hour = 3_600_000_u64;
1153        let now = 2 * one_hour;
1154        // Object created exactly one hour ago: created_at = now - one_hour
1155        let mut d = obj("a", now - one_hour);
1156        d.state = OlmLifecycleState::Deleted;
1157        mgr.register_object(d).unwrap();
1158        // threshold = now - one_hour = created_at → created_at <= threshold → purged
1159        let count = mgr.purge_deleted(now);
1160        assert_eq!(count, 1);
1161    }
1162
1163    // ── tags and namespace ────────────────────────────────────────────────────
1164
1165    #[test]
1166    fn test_managed_object_with_tags() {
1167        let mut o = obj("tagged", 0);
1168        o.tags.insert("env".to_owned(), "prod".to_owned());
1169        o.namespace = "billing".to_owned();
1170        let mut mgr = ObjectLifecycleManager::new(None);
1171        mgr.register_object(o).unwrap();
1172        let retrieved = mgr.unregister_object("tagged").unwrap();
1173        assert_eq!(retrieved.tags.get("env").map(String::as_str), Some("prod"));
1174        assert_eq!(retrieved.namespace, "billing");
1175    }
1176
1177    // ── retention_rule association ────────────────────────────────────────────
1178
1179    #[test]
1180    fn test_rule_association_recorded_when_matched_but_not_expired() {
1181        let mut mgr = ObjectLifecycleManager::new(None);
1182        // Rule: matches when age >= 50, but expires only at >= 10_000
1183        mgr.add_rule(rule("slow_expire", 50, Some(10_000), Some("warm"), 1));
1184        mgr.register_object(obj("a", 0)).unwrap();
1185        // age = 100: matches min_age but not max_age yet
1186        let actions = mgr.apply_rules(100);
1187        assert!(actions.is_empty());
1188        // The rule name should be recorded on the object
1189        let o = mgr.unregister_object("a").unwrap();
1190        assert_eq!(o.retention_rule.as_deref(), Some("slow_expire"));
1191    }
1192}