use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum LifecycleError {
#[error("object already exists: {0}")]
ObjectAlreadyExists(String),
#[error("object not found: {0}")]
ObjectNotFound(String),
#[error("invalid state for object {object_id}: current={current}, expected={expected}")]
InvalidState {
object_id: String,
current: String,
expected: String,
},
#[error("rule not found: {0}")]
RuleNotFound(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OlmLifecycleState {
Active,
Expiring { expires_at: u64 },
Archived,
MarkedForDeletion,
Deleted,
}
impl OlmLifecycleState {
pub fn name(&self) -> &'static str {
match self {
OlmLifecycleState::Active => "Active",
OlmLifecycleState::Expiring { .. } => "Expiring",
OlmLifecycleState::Archived => "Archived",
OlmLifecycleState::MarkedForDeletion => "MarkedForDeletion",
OlmLifecycleState::Deleted => "Deleted",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OlmRetentionRule {
pub name: String,
pub min_age_ms: u64,
pub max_age_ms: Option<u64>,
pub transition_to: Option<String>,
pub priority: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagedObject {
pub object_id: String,
pub namespace: String,
pub size_bytes: u64,
pub created_at: u64,
pub last_accessed: u64,
pub state: OlmLifecycleState,
pub current_tier: String,
pub tags: HashMap<String, String>,
pub retention_rule: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OlmLifecycleAction {
Transition {
object_id: String,
from_tier: String,
to_tier: String,
},
Delete { object_id: String, reason: String },
Archive { object_id: String },
SetExpiring { object_id: String, expires_at: u64 },
}
impl OlmLifecycleAction {
pub fn object_id(&self) -> &str {
match self {
OlmLifecycleAction::Transition { object_id, .. } => object_id,
OlmLifecycleAction::Delete { object_id, .. } => object_id,
OlmLifecycleAction::Archive { object_id } => object_id,
OlmLifecycleAction::SetExpiring { object_id, .. } => object_id,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OlmLifecycleStats {
pub total_objects: usize,
pub active_count: usize,
pub expiring_count: usize,
pub archived_count: usize,
pub marked_for_deletion_count: usize,
pub deleted_count: usize,
pub total_active_bytes: u64,
pub rules_count: usize,
}
pub struct ObjectLifecycleManager {
objects: HashMap<String, ManagedObject>,
rules: Vec<OlmRetentionRule>,
default_ttl_ms: Option<u64>,
rule_insertion_order: Vec<String>,
}
impl ObjectLifecycleManager {
pub fn new(default_ttl_ms: Option<u64>) -> Self {
Self {
objects: HashMap::new(),
rules: Vec::new(),
default_ttl_ms,
rule_insertion_order: Vec::new(),
}
}
pub fn add_rule(&mut self, rule: OlmRetentionRule) {
self.rule_insertion_order.push(rule.name.clone());
self.rules.push(rule);
}
pub fn register_object(&mut self, object: ManagedObject) -> Result<(), LifecycleError> {
if self.objects.contains_key(&object.object_id) {
return Err(LifecycleError::ObjectAlreadyExists(
object.object_id.clone(),
));
}
self.objects.insert(object.object_id.clone(), object);
Ok(())
}
pub fn unregister_object(&mut self, object_id: &str) -> Result<ManagedObject, LifecycleError> {
self.objects
.remove(object_id)
.ok_or_else(|| LifecycleError::ObjectNotFound(object_id.to_owned()))
}
pub fn access_object(&mut self, object_id: &str, now: u64) -> Result<(), LifecycleError> {
let obj = self
.objects
.get_mut(object_id)
.ok_or_else(|| LifecycleError::ObjectNotFound(object_id.to_owned()))?;
if obj.state == OlmLifecycleState::Deleted {
return Err(LifecycleError::InvalidState {
object_id: object_id.to_owned(),
current: "Deleted".to_owned(),
expected: "not Deleted".to_owned(),
});
}
obj.last_accessed = now;
Ok(())
}
pub fn apply_rules(&mut self, now: u64) -> Vec<OlmLifecycleAction> {
let sorted_indices = self.sorted_rule_indices();
let mut actions: Vec<OlmLifecycleAction> = Vec::new();
let ids: Vec<String> = self.objects.keys().cloned().collect();
for id in ids {
let obj = match self.objects.get(&id) {
Some(o) => o,
None => continue,
};
if obj.state == OlmLifecycleState::Deleted {
continue;
}
let age_ms = now.saturating_sub(obj.created_at);
let mut matched_rule: Option<usize> = None;
for &rule_idx in &sorted_indices {
let rule = &self.rules[rule_idx];
if age_ms >= rule.min_age_ms {
matched_rule = Some(rule_idx);
break; }
}
if let Some(rule_idx) = matched_rule {
let rule = self.rules[rule_idx].clone();
if let Some(max_age) = rule.max_age_ms {
if age_ms >= max_age {
let action = self.build_rule_action(id.clone(), &rule);
self.apply_action_to_object(&id, &action);
actions.push(action);
continue;
}
}
if let Some(obj_mut) = self.objects.get_mut(&id) {
obj_mut.retention_rule = Some(rule.name.clone());
}
} else {
if let Some(ttl) = self.default_ttl_ms {
if age_ms >= ttl {
let action = OlmLifecycleAction::Delete {
object_id: id.clone(),
reason: "default_ttl_expired".to_owned(),
};
self.apply_action_to_object(&id, &action);
actions.push(action);
}
}
}
}
actions
}
pub fn execute_action(
&mut self,
action: &OlmLifecycleAction,
_now: u64,
) -> Result<(), LifecycleError> {
let id = action.object_id();
if !self.objects.contains_key(id) {
return Err(LifecycleError::ObjectNotFound(id.to_owned()));
}
self.apply_action_to_object(id, action);
Ok(())
}
pub fn objects_in_state(&self, state_name: &str) -> Vec<&ManagedObject> {
self.objects
.values()
.filter(|o| o.state.name() == state_name)
.collect()
}
pub fn objects_expiring_before(&self, timestamp: u64) -> Vec<&ManagedObject> {
self.objects
.values()
.filter(|o| {
matches!(
&o.state,
OlmLifecycleState::Expiring { expires_at }
if *expires_at <= timestamp
)
})
.collect()
}
pub fn objects_by_tier(&self, tier: &str) -> Vec<&ManagedObject> {
let mut result: Vec<&ManagedObject> = self
.objects
.values()
.filter(|o| o.current_tier == tier)
.collect();
result.sort_by_key(|o| o.created_at);
result
}
pub fn purge_deleted(&mut self, now: u64) -> usize {
const ONE_HOUR_MS: u64 = 3_600_000;
let threshold = now.saturating_sub(ONE_HOUR_MS);
let to_remove: Vec<String> = self
.objects
.iter()
.filter(|(_, o)| o.state == OlmLifecycleState::Deleted && o.created_at <= threshold)
.map(|(id, _)| id.clone())
.collect();
let count = to_remove.len();
for id in to_remove {
self.objects.remove(&id);
}
count
}
pub fn total_active_bytes(&self) -> u64 {
self.objects
.values()
.filter(|o| {
matches!(
o.state,
OlmLifecycleState::Active | OlmLifecycleState::Expiring { .. }
)
})
.map(|o| o.size_bytes)
.sum()
}
pub fn stats(&self) -> OlmLifecycleStats {
let mut s = OlmLifecycleStats {
total_objects: self.objects.len(),
rules_count: self.rules.len(),
..Default::default()
};
for obj in self.objects.values() {
match &obj.state {
OlmLifecycleState::Active => {
s.active_count += 1;
s.total_active_bytes += obj.size_bytes;
}
OlmLifecycleState::Expiring { .. } => {
s.expiring_count += 1;
s.total_active_bytes += obj.size_bytes;
}
OlmLifecycleState::Archived => s.archived_count += 1,
OlmLifecycleState::MarkedForDeletion => s.marked_for_deletion_count += 1,
OlmLifecycleState::Deleted => s.deleted_count += 1,
}
}
s
}
fn sorted_rule_indices(&self) -> Vec<usize> {
let mut indices: Vec<usize> = (0..self.rules.len()).collect();
indices.sort_by(|&a, &b| {
let pa = self.rules[a].priority;
let pb = self.rules[b].priority;
if pa != pb {
return pb.cmp(&pa); }
let ia = self
.rule_insertion_order
.iter()
.position(|n| n == &self.rules[a].name)
.unwrap_or(usize::MAX);
let ib = self
.rule_insertion_order
.iter()
.position(|n| n == &self.rules[b].name)
.unwrap_or(usize::MAX);
ia.cmp(&ib)
});
indices
}
fn build_rule_action(&self, object_id: String, rule: &OlmRetentionRule) -> OlmLifecycleAction {
match &rule.transition_to {
Some(to_tier) => {
let from_tier = self
.objects
.get(&object_id)
.map(|o| o.current_tier.clone())
.unwrap_or_default();
OlmLifecycleAction::Transition {
object_id,
from_tier,
to_tier: to_tier.clone(),
}
}
None => OlmLifecycleAction::Delete {
object_id,
reason: format!("rule:{}", rule.name),
},
}
}
fn apply_action_to_object(&mut self, object_id: &str, action: &OlmLifecycleAction) {
let obj = match self.objects.get_mut(object_id) {
Some(o) => o,
None => return,
};
match action {
OlmLifecycleAction::Transition { to_tier, .. } => {
obj.current_tier = to_tier.clone();
if obj.state == OlmLifecycleState::Active {
}
}
OlmLifecycleAction::Delete { .. } => {
obj.state = OlmLifecycleState::Deleted;
}
OlmLifecycleAction::Archive { .. } => {
obj.state = OlmLifecycleState::Archived;
}
OlmLifecycleAction::SetExpiring { expires_at, .. } => {
obj.state = OlmLifecycleState::Expiring {
expires_at: *expires_at,
};
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
LifecycleError, ManagedObject, ObjectLifecycleManager, OlmLifecycleAction,
OlmLifecycleState, OlmRetentionRule,
};
use std::collections::HashMap;
fn obj(id: &str, created_at: u64) -> ManagedObject {
ManagedObject {
object_id: id.to_owned(),
namespace: "test".to_owned(),
size_bytes: 1024,
created_at,
last_accessed: created_at,
state: OlmLifecycleState::Active,
current_tier: "hot".to_owned(),
tags: HashMap::new(),
retention_rule: None,
}
}
fn rule(
name: &str,
min_age_ms: u64,
max_age_ms: Option<u64>,
transition_to: Option<&str>,
priority: u32,
) -> OlmRetentionRule {
OlmRetentionRule {
name: name.to_owned(),
min_age_ms,
max_age_ms,
transition_to: transition_to.map(|s| s.to_owned()),
priority,
}
}
#[test]
fn test_new_no_ttl() {
let mgr = ObjectLifecycleManager::new(None);
let s = mgr.stats();
assert_eq!(s.total_objects, 0);
assert_eq!(s.rules_count, 0);
}
#[test]
fn test_new_with_ttl() {
let mgr = ObjectLifecycleManager::new(Some(60_000));
let s = mgr.stats();
assert_eq!(s.total_objects, 0);
}
#[test]
fn test_register_object_ok() {
let mut mgr = ObjectLifecycleManager::new(None);
assert!(mgr.register_object(obj("a", 0)).is_ok());
assert_eq!(mgr.stats().total_objects, 1);
}
#[test]
fn test_register_duplicate_error() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let err = mgr.register_object(obj("a", 0)).unwrap_err();
assert!(matches!(err, LifecycleError::ObjectAlreadyExists(id) if id == "a"));
}
#[test]
fn test_unregister_ok() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let removed = mgr.unregister_object("a").unwrap();
assert_eq!(removed.object_id, "a");
assert_eq!(mgr.stats().total_objects, 0);
}
#[test]
fn test_unregister_not_found() {
let mut mgr = ObjectLifecycleManager::new(None);
let err = mgr.unregister_object("missing").unwrap_err();
assert!(matches!(err, LifecycleError::ObjectNotFound(_)));
}
#[test]
fn test_access_updates_last_accessed() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 100)).unwrap();
mgr.access_object("a", 999).unwrap();
let o = mgr.unregister_object("a").unwrap();
assert_eq!(o.last_accessed, 999);
}
#[test]
fn test_access_deleted_returns_error() {
let mut mgr = ObjectLifecycleManager::new(None);
let mut o = obj("a", 0);
o.state = OlmLifecycleState::Deleted;
mgr.register_object(o).unwrap();
let err = mgr.access_object("a", 1).unwrap_err();
assert!(matches!(err, LifecycleError::InvalidState { .. }));
}
#[test]
fn test_access_not_found() {
let mut mgr = ObjectLifecycleManager::new(None);
let err = mgr.access_object("nope", 1).unwrap_err();
assert!(matches!(err, LifecycleError::ObjectNotFound(_)));
}
#[test]
fn test_default_ttl_fires_delete() {
let mut mgr = ObjectLifecycleManager::new(Some(1000));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(2000);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
OlmLifecycleAction::Delete { object_id, reason }
if object_id == "a" && reason == "default_ttl_expired"
));
}
#[test]
fn test_default_ttl_not_fired_when_young() {
let mut mgr = ObjectLifecycleManager::new(Some(5000));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(100);
assert!(actions.is_empty());
}
#[test]
fn test_no_default_ttl_no_action() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(999_999);
assert!(actions.is_empty());
}
#[test]
fn test_rule_transition_fires() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("to_warm", 100, Some(500), Some("warm"), 10));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(600);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
OlmLifecycleAction::Transition { object_id, to_tier, .. }
if object_id == "a" && to_tier == "warm"
));
}
#[test]
fn test_rule_delete_fires_when_no_transition() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("delete_old", 0, Some(100), None, 5));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(200);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
OlmLifecycleAction::Delete { object_id, .. }
if object_id == "a"
));
}
#[test]
fn test_rule_not_fired_before_max_age() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("to_cold", 0, Some(10_000), Some("cold"), 1));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(5_000);
assert!(actions.is_empty());
}
#[test]
fn test_rule_not_matched_before_min_age() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("old_only", 10_000, Some(20_000), Some("cold"), 1));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(5_000);
assert!(actions.is_empty());
}
#[test]
fn test_higher_priority_rule_wins() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("delete_low", 0, Some(100), None, 1));
mgr.add_rule(rule("transit_high", 0, Some(100), Some("warm"), 10));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(200);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
OlmLifecycleAction::Transition { to_tier, .. }
if to_tier == "warm"
));
}
#[test]
fn test_equal_priority_insertion_order() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("first", 0, Some(100), Some("warm"), 5));
mgr.add_rule(rule("second", 0, Some(100), Some("cold"), 5));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(200);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
OlmLifecycleAction::Transition { to_tier, .. }
if to_tier == "warm"
));
}
#[test]
fn test_apply_rules_marks_object_deleted() {
let mut mgr = ObjectLifecycleManager::new(Some(1000));
mgr.register_object(obj("a", 0)).unwrap();
mgr.apply_rules(2000);
let objs = mgr.objects_in_state("Deleted");
assert_eq!(objs.len(), 1);
assert_eq!(objs[0].object_id, "a");
}
#[test]
fn test_deleted_object_skipped_on_second_run() {
let mut mgr = ObjectLifecycleManager::new(Some(500));
mgr.register_object(obj("a", 0)).unwrap();
let a1 = mgr.apply_rules(1000);
let a2 = mgr.apply_rules(2000);
assert_eq!(a1.len(), 1);
assert!(a2.is_empty(), "Deleted objects must be skipped");
}
#[test]
fn test_execute_action_delete() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let action = OlmLifecycleAction::Delete {
object_id: "a".to_owned(),
reason: "manual".to_owned(),
};
mgr.execute_action(&action, 100).unwrap();
assert_eq!(mgr.objects_in_state("Deleted").len(), 1);
}
#[test]
fn test_execute_action_archive() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let action = OlmLifecycleAction::Archive {
object_id: "a".to_owned(),
};
mgr.execute_action(&action, 100).unwrap();
assert_eq!(mgr.objects_in_state("Archived").len(), 1);
}
#[test]
fn test_execute_action_set_expiring() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let action = OlmLifecycleAction::SetExpiring {
object_id: "a".to_owned(),
expires_at: 5000,
};
mgr.execute_action(&action, 100).unwrap();
assert_eq!(mgr.objects_in_state("Expiring").len(), 1);
}
#[test]
fn test_execute_action_transition_updates_tier() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let action = OlmLifecycleAction::Transition {
object_id: "a".to_owned(),
from_tier: "hot".to_owned(),
to_tier: "cold".to_owned(),
};
mgr.execute_action(&action, 100).unwrap();
let tiers = mgr.objects_by_tier("cold");
assert_eq!(tiers.len(), 1);
}
#[test]
fn test_execute_action_not_found() {
let mut mgr = ObjectLifecycleManager::new(None);
let action = OlmLifecycleAction::Delete {
object_id: "ghost".to_owned(),
reason: "test".to_owned(),
};
let err = mgr.execute_action(&action, 0).unwrap_err();
assert!(matches!(err, LifecycleError::ObjectNotFound(_)));
}
#[test]
fn test_objects_in_state_active() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
mgr.register_object(obj("b", 0)).unwrap();
assert_eq!(mgr.objects_in_state("Active").len(), 2);
assert_eq!(mgr.objects_in_state("Deleted").len(), 0);
}
#[test]
fn test_objects_in_state_unknown_returns_empty() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
assert!(mgr.objects_in_state("NonExistent").is_empty());
}
#[test]
fn test_objects_expiring_before() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
mgr.register_object(obj("b", 0)).unwrap();
let a1 = OlmLifecycleAction::SetExpiring {
object_id: "a".to_owned(),
expires_at: 100,
};
let a2 = OlmLifecycleAction::SetExpiring {
object_id: "b".to_owned(),
expires_at: 9999,
};
mgr.execute_action(&a1, 0).unwrap();
mgr.execute_action(&a2, 0).unwrap();
let expiring = mgr.objects_expiring_before(500);
assert_eq!(expiring.len(), 1);
assert_eq!(expiring[0].object_id, "a");
}
#[test]
fn test_objects_expiring_at_exact_boundary() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap();
let action = OlmLifecycleAction::SetExpiring {
object_id: "a".to_owned(),
expires_at: 100,
};
mgr.execute_action(&action, 0).unwrap();
assert_eq!(mgr.objects_expiring_before(100).len(), 1);
assert_eq!(mgr.objects_expiring_before(99).len(), 0);
}
#[test]
fn test_objects_by_tier_sorted_by_created_at() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("c", 300)).unwrap();
mgr.register_object(obj("a", 100)).unwrap();
mgr.register_object(obj("b", 200)).unwrap();
let result = mgr.objects_by_tier("hot");
assert_eq!(result.len(), 3);
assert_eq!(result[0].object_id, "a");
assert_eq!(result[1].object_id, "b");
assert_eq!(result[2].object_id, "c");
}
#[test]
fn test_objects_by_tier_filter_by_tier_name() {
let mut mgr = ObjectLifecycleManager::new(None);
let mut warm_obj = obj("w", 0);
warm_obj.current_tier = "warm".to_owned();
mgr.register_object(obj("h", 0)).unwrap();
mgr.register_object(warm_obj).unwrap();
assert_eq!(mgr.objects_by_tier("hot").len(), 1);
assert_eq!(mgr.objects_by_tier("warm").len(), 1);
assert_eq!(mgr.objects_by_tier("cold").len(), 0);
}
#[test]
fn test_purge_deleted_removes_old_deleted_objects() {
let mut mgr = ObjectLifecycleManager::new(None);
let mut d = obj("a", 0); d.state = OlmLifecycleState::Deleted;
mgr.register_object(d).unwrap();
let count = mgr.purge_deleted(4_000_000);
assert_eq!(count, 1);
assert_eq!(mgr.stats().total_objects, 0);
}
#[test]
fn test_purge_deleted_keeps_recent_deleted_objects() {
let mut mgr = ObjectLifecycleManager::new(None);
let now = 3_600_000_u64; let mut d = obj("a", now - 100); d.state = OlmLifecycleState::Deleted;
mgr.register_object(d).unwrap();
let count = mgr.purge_deleted(now);
assert_eq!(count, 0);
assert_eq!(mgr.stats().total_objects, 1);
}
#[test]
fn test_purge_deleted_ignores_active_objects() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("a", 0)).unwrap(); let count = mgr.purge_deleted(9_999_999);
assert_eq!(count, 0);
assert_eq!(mgr.stats().total_objects, 1);
}
#[test]
fn test_total_active_bytes_sums_active_and_expiring() {
let mut mgr = ObjectLifecycleManager::new(None);
let mut o1 = obj("a", 0);
o1.size_bytes = 500;
let mut o2 = obj("b", 0);
o2.size_bytes = 300;
o2.state = OlmLifecycleState::Expiring { expires_at: 9999 };
let mut o3 = obj("c", 0);
o3.size_bytes = 200;
o3.state = OlmLifecycleState::Archived;
mgr.register_object(o1).unwrap();
mgr.register_object(o2).unwrap();
mgr.register_object(o3).unwrap();
assert_eq!(mgr.total_active_bytes(), 800);
}
#[test]
fn test_total_active_bytes_excludes_deleted() {
let mut mgr = ObjectLifecycleManager::new(None);
let mut o = obj("a", 0);
o.size_bytes = 1000;
o.state = OlmLifecycleState::Deleted;
mgr.register_object(o).unwrap();
assert_eq!(mgr.total_active_bytes(), 0);
}
#[test]
fn test_stats_counts_all_states() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(obj("active", 0)).unwrap();
let mut exp = obj("exp", 0);
exp.state = OlmLifecycleState::Expiring { expires_at: 1 };
mgr.register_object(exp).unwrap();
let mut arch = obj("arch", 0);
arch.state = OlmLifecycleState::Archived;
mgr.register_object(arch).unwrap();
let mut mfd = obj("mfd", 0);
mfd.state = OlmLifecycleState::MarkedForDeletion;
mgr.register_object(mfd).unwrap();
let mut del = obj("del", 0);
del.state = OlmLifecycleState::Deleted;
mgr.register_object(del).unwrap();
let s = mgr.stats();
assert_eq!(s.total_objects, 5);
assert_eq!(s.active_count, 1);
assert_eq!(s.expiring_count, 1);
assert_eq!(s.archived_count, 1);
assert_eq!(s.marked_for_deletion_count, 1);
assert_eq!(s.deleted_count, 1);
}
#[test]
fn test_stats_rules_count() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("r1", 0, None, None, 1));
mgr.add_rule(rule("r2", 0, None, None, 2));
assert_eq!(mgr.stats().rules_count, 2);
}
#[test]
fn test_rule_without_max_age_does_not_fire() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("forever", 0, None, Some("warm"), 1));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(999_999_999);
assert!(actions.is_empty());
}
#[test]
fn test_multiple_objects_independent_rules() {
let mut mgr = ObjectLifecycleManager::new(Some(1000));
mgr.register_object(obj("young", 500)).unwrap(); mgr.register_object(obj("old", 0)).unwrap(); let actions = mgr.apply_rules(1000);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
OlmLifecycleAction::Delete { object_id, .. }
if object_id == "old"
));
}
#[test]
fn test_apply_rules_multiple_objects_multiple_actions() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("to_warm", 0, Some(100), Some("warm"), 1));
for i in 0..5_u64 {
mgr.register_object(obj(&format!("o{i}"), 0)).unwrap();
}
let actions = mgr.apply_rules(200);
assert_eq!(actions.len(), 5);
for action in &actions {
assert!(
matches!(action, OlmLifecycleAction::Transition { to_tier, .. } if to_tier == "warm")
);
}
}
#[test]
fn test_state_name_variants() {
assert_eq!(OlmLifecycleState::Active.name(), "Active");
assert_eq!(
OlmLifecycleState::Expiring { expires_at: 0 }.name(),
"Expiring"
);
assert_eq!(OlmLifecycleState::Archived.name(), "Archived");
assert_eq!(
OlmLifecycleState::MarkedForDeletion.name(),
"MarkedForDeletion"
);
assert_eq!(OlmLifecycleState::Deleted.name(), "Deleted");
}
#[test]
fn test_action_object_id() {
let t = OlmLifecycleAction::Transition {
object_id: "x".to_owned(),
from_tier: "a".to_owned(),
to_tier: "b".to_owned(),
};
assert_eq!(t.object_id(), "x");
let d = OlmLifecycleAction::Delete {
object_id: "y".to_owned(),
reason: "r".to_owned(),
};
assert_eq!(d.object_id(), "y");
let ar = OlmLifecycleAction::Archive {
object_id: "z".to_owned(),
};
assert_eq!(ar.object_id(), "z");
let se = OlmLifecycleAction::SetExpiring {
object_id: "w".to_owned(),
expires_at: 0,
};
assert_eq!(se.object_id(), "w");
}
#[test]
fn test_error_display_already_exists() {
let e = LifecycleError::ObjectAlreadyExists("abc".to_owned());
assert!(e.to_string().contains("abc"));
}
#[test]
fn test_error_display_not_found() {
let e = LifecycleError::ObjectNotFound("xyz".to_owned());
assert!(e.to_string().contains("xyz"));
}
#[test]
fn test_error_display_invalid_state() {
let e = LifecycleError::InvalidState {
object_id: "o1".to_owned(),
current: "Deleted".to_owned(),
expected: "Active".to_owned(),
};
let s = e.to_string();
assert!(s.contains("o1"));
assert!(s.contains("Deleted"));
assert!(s.contains("Active"));
}
#[test]
fn test_error_display_rule_not_found() {
let e = LifecycleError::RuleNotFound("r99".to_owned());
assert!(e.to_string().contains("r99"));
}
#[test]
fn test_purge_deleted_exactly_one_hour_boundary() {
let mut mgr = ObjectLifecycleManager::new(None);
let one_hour = 3_600_000_u64;
let now = 2 * one_hour;
let mut d = obj("a", now - one_hour);
d.state = OlmLifecycleState::Deleted;
mgr.register_object(d).unwrap();
let count = mgr.purge_deleted(now);
assert_eq!(count, 1);
}
#[test]
fn test_managed_object_with_tags() {
let mut o = obj("tagged", 0);
o.tags.insert("env".to_owned(), "prod".to_owned());
o.namespace = "billing".to_owned();
let mut mgr = ObjectLifecycleManager::new(None);
mgr.register_object(o).unwrap();
let retrieved = mgr.unregister_object("tagged").unwrap();
assert_eq!(retrieved.tags.get("env").map(String::as_str), Some("prod"));
assert_eq!(retrieved.namespace, "billing");
}
#[test]
fn test_rule_association_recorded_when_matched_but_not_expired() {
let mut mgr = ObjectLifecycleManager::new(None);
mgr.add_rule(rule("slow_expire", 50, Some(10_000), Some("warm"), 1));
mgr.register_object(obj("a", 0)).unwrap();
let actions = mgr.apply_rules(100);
assert!(actions.is_empty());
let o = mgr.unregister_object("a").unwrap();
assert_eq!(o.retention_rule.as_deref(), Some("slow_expire"));
}
}