1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Debug, Error, Clone, PartialEq, Eq)]
15pub enum LifecycleError {
16 #[error("object already exists: {0}")]
18 ObjectAlreadyExists(String),
19
20 #[error("object not found: {0}")]
22 ObjectNotFound(String),
23
24 #[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 #[error("rule not found: {0}")]
34 RuleNotFound(String),
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub enum OlmLifecycleState {
42 Active,
44 Expiring { expires_at: u64 },
46 Archived,
48 MarkedForDeletion,
50 Deleted,
52}
53
54impl OlmLifecycleState {
55 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#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct OlmRetentionRule {
72 pub name: String,
74
75 pub min_age_ms: u64,
77
78 pub max_age_ms: Option<u64>,
80
81 pub transition_to: Option<String>,
83
84 pub priority: u32,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ManagedObject {
93 pub object_id: String,
95
96 pub namespace: String,
98
99 pub size_bytes: u64,
101
102 pub created_at: u64,
104
105 pub last_accessed: u64,
107
108 pub state: OlmLifecycleState,
110
111 pub current_tier: String,
113
114 pub tags: HashMap<String, String>,
116
117 pub retention_rule: Option<String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub enum OlmLifecycleAction {
126 Transition {
128 object_id: String,
129 from_tier: String,
130 to_tier: String,
131 },
132 Delete { object_id: String, reason: String },
134 Archive { object_id: String },
136 SetExpiring { object_id: String, expires_at: u64 },
138}
139
140impl OlmLifecycleAction {
141 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#[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
167pub struct ObjectLifecycleManager {
176 objects: HashMap<String, ManagedObject>,
177 rules: Vec<OlmRetentionRule>,
178 default_ttl_ms: Option<u64>,
179 rule_insertion_order: Vec<String>,
181}
182
183impl ObjectLifecycleManager {
184 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 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 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 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 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 pub fn apply_rules(&mut self, now: u64) -> Vec<OlmLifecycleAction> {
260 let sorted_indices = self.sorted_rule_indices();
262
263 let mut actions: Vec<OlmLifecycleAction> = Vec::new();
264
265 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 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; 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; }
289 }
290
291 if let Some(rule_idx) = matched_rule {
292 let rule = self.rules[rule_idx].clone();
293
294 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 if let Some(obj_mut) = self.objects.get_mut(&id) {
306 obj_mut.retention_rule = Some(rule.name.clone());
307 }
308 } else {
309 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 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 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 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 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 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 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 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 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); }
455 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 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 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 if obj.state == OlmLifecycleState::Active {
505 }
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#[cfg(test)]
526mod tests {
527 use super::{
528 LifecycleError, ManagedObject, ObjectLifecycleManager, OlmLifecycleAction,
529 OlmLifecycleState, OlmRetentionRule,
530 };
531 use std::collections::HashMap;
532
533 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 #[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 #[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 #[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 #[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 #[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 let actions = mgr.apply_rules(5_000);
720 assert!(actions.is_empty());
721 }
722
723 #[test]
726 fn test_higher_priority_rule_wins() {
727 let mut mgr = ObjectLifecycleManager::new(None);
728 mgr.add_rule(rule("delete_low", 0, Some(100), None, 1));
730 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 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 #[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 #[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 #[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 #[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 assert_eq!(mgr.objects_expiring_before(100).len(), 1);
894 assert_eq!(mgr.objects_expiring_before(99).len(), 0);
895 }
896
897 #[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 #[test]
927 fn test_purge_deleted_removes_old_deleted_objects() {
928 let mut mgr = ObjectLifecycleManager::new(None);
929 let mut d = obj("a", 0); 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; let mut d = obj("a", now - 100); 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(); let count = mgr.purge_deleted(9_999_999);
954 assert_eq!(count, 0);
955 assert_eq!(mgr.stats().total_objects, 1);
956 }
957
958 #[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 #[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 #[test]
1026 fn test_rule_without_max_age_does_not_fire() {
1027 let mut mgr = ObjectLifecycleManager::new(None);
1028 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 #[test]
1038 fn test_multiple_objects_independent_rules() {
1039 let mut mgr = ObjectLifecycleManager::new(Some(1000));
1040 mgr.register_object(obj("young", 500)).unwrap(); mgr.register_object(obj("old", 0)).unwrap(); let actions = mgr.apply_rules(1000);
1043 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 #[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 #[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 #[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 #[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 let mut d = obj("a", now - one_hour);
1156 d.state = OlmLifecycleState::Deleted;
1157 mgr.register_object(d).unwrap();
1158 let count = mgr.purge_deleted(now);
1160 assert_eq!(count, 1);
1161 }
1162
1163 #[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 #[test]
1180 fn test_rule_association_recorded_when_matched_but_not_expired() {
1181 let mut mgr = ObjectLifecycleManager::new(None);
1182 mgr.add_rule(rule("slow_expire", 50, Some(10_000), Some("warm"), 1));
1184 mgr.register_object(obj("a", 0)).unwrap();
1185 let actions = mgr.apply_rules(100);
1187 assert!(actions.is_empty());
1188 let o = mgr.unregister_object("a").unwrap();
1190 assert_eq!(o.retention_rule.as_deref(), Some("slow_expire"));
1191 }
1192}