use std::collections::HashMap;
use std::sync::RwLock;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LifecycleStatus {
Enabled,
Disabled,
}
impl LifecycleStatus {
#[must_use]
pub fn as_aws_str(self) -> &'static str {
match self {
Self::Enabled => "Enabled",
Self::Disabled => "Disabled",
}
}
#[must_use]
pub fn from_aws_str(s: &str) -> Self {
if s.eq_ignore_ascii_case("Enabled") {
Self::Enabled
} else {
Self::Disabled
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleFilter {
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub tags: Vec<(String, String)>,
#[serde(default)]
pub object_size_greater_than: Option<u64>,
#[serde(default)]
pub object_size_less_than: Option<u64>,
}
impl LifecycleFilter {
#[must_use]
pub fn matches(&self, key: &str, size: u64, object_tags: &[(String, String)]) -> bool {
if let Some(p) = &self.prefix
&& !key.starts_with(p)
{
return false;
}
if let Some(min) = self.object_size_greater_than
&& size <= min
{
return false;
}
if let Some(max) = self.object_size_less_than
&& size >= max
{
return false;
}
for (tk, tv) in &self.tags {
let matched = object_tags.iter().any(|(ok, ov)| ok == tk && ov == tv);
if !matched {
return false;
}
}
true
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionRule {
pub days: u32,
pub storage_class: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleRule {
pub id: String,
pub status: LifecycleStatus,
#[serde(default)]
pub filter: LifecycleFilter,
#[serde(default)]
pub expiration_days: Option<u32>,
#[serde(default)]
pub expiration_date: Option<DateTime<Utc>>,
#[serde(default)]
pub transitions: Vec<TransitionRule>,
#[serde(default)]
pub noncurrent_version_expiration_days: Option<u32>,
#[serde(default)]
pub abort_incomplete_multipart_upload_days: Option<u32>,
}
impl LifecycleRule {
#[must_use]
pub fn expire_after_days(id: impl Into<String>, days: u32) -> Self {
Self {
id: id.into(),
status: LifecycleStatus::Enabled,
filter: LifecycleFilter::default(),
expiration_days: Some(days),
expiration_date: None,
transitions: Vec::new(),
noncurrent_version_expiration_days: None,
abort_incomplete_multipart_upload_days: None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleConfig {
pub rules: Vec<LifecycleRule>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LifecycleAction {
Expire,
Transition { storage_class: String },
}
impl LifecycleAction {
#[must_use]
pub fn metric_label(&self) -> &'static str {
match self {
Self::Expire => "expire",
Self::Transition { .. } => "transition",
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct LifecycleSnapshot {
by_bucket: HashMap<String, LifecycleConfig>,
}
#[derive(Debug, Default)]
pub struct LifecycleManager {
by_bucket: RwLock<HashMap<String, LifecycleConfig>>,
actions_total: RwLock<HashMap<(String, String), u64>>,
}
impl LifecycleManager {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn put(&self, bucket: &str, config: LifecycleConfig) {
self.by_bucket
.write()
.expect("lifecycle state RwLock poisoned")
.insert(bucket.to_owned(), config);
}
#[must_use]
pub fn get(&self, bucket: &str) -> Option<LifecycleConfig> {
self.by_bucket
.read()
.expect("lifecycle state RwLock poisoned")
.get(bucket)
.cloned()
}
pub fn delete(&self, bucket: &str) {
self.by_bucket
.write()
.expect("lifecycle state RwLock poisoned")
.remove(bucket);
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
let by_bucket = self
.by_bucket
.read()
.expect("lifecycle state RwLock poisoned")
.clone();
let snap = LifecycleSnapshot { by_bucket };
serde_json::to_string(&snap)
}
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
let snap: LifecycleSnapshot = serde_json::from_str(s)?;
Ok(Self {
by_bucket: RwLock::new(snap.by_bucket),
actions_total: RwLock::new(HashMap::new()),
})
}
#[must_use]
pub fn evaluate(
&self,
bucket: &str,
key: &str,
object_age: Duration,
object_size: u64,
object_tags: &[(String, String)],
) -> Option<LifecycleAction> {
self.evaluate_with_flags(
bucket,
key,
object_age,
object_size,
object_tags,
EvaluateFlags::default(),
)
}
#[must_use]
pub fn evaluate_with_flags(
&self,
bucket: &str,
key: &str,
object_age: Duration,
object_size: u64,
object_tags: &[(String, String)],
flags: EvaluateFlags,
) -> Option<LifecycleAction> {
let cfg = self.get(bucket)?;
let now_for_date = flags.now.unwrap_or_else(Utc::now);
let age_days = object_age.num_days().max(0);
let age_days_u32 = u32::try_from(age_days).unwrap_or(u32::MAX);
for rule in &cfg.rules {
if rule.status != LifecycleStatus::Enabled {
continue;
}
if !rule.filter.matches(key, object_size, object_tags) {
continue;
}
if flags.is_noncurrent {
if let Some(days) = rule.noncurrent_version_expiration_days
&& age_days_u32 >= days
{
return Some(LifecycleAction::Expire);
}
continue;
}
let exp_days_match = rule.expiration_days.filter(|d| age_days_u32 >= *d);
let exp_date_match = rule.expiration_date.filter(|d| now_for_date >= *d);
let chosen_transition = rule
.transitions
.iter()
.filter(|t| age_days_u32 >= t.days)
.max_by_key(|t| t.days);
if let Some(exp_threshold) = exp_days_match {
let trans_threshold = chosen_transition.map(|t| t.days).unwrap_or(u32::MAX);
if exp_threshold <= trans_threshold {
return Some(LifecycleAction::Expire);
}
}
if let Some(t) = chosen_transition {
return Some(LifecycleAction::Transition {
storage_class: t.storage_class.clone(),
});
}
if exp_date_match.is_some() {
return Some(LifecycleAction::Expire);
}
}
None
}
pub fn record_action(&self, bucket: &str, action: &LifecycleAction) {
let label = action.metric_label();
let key = (bucket.to_owned(), label.to_owned());
let mut guard = self
.actions_total
.write()
.expect("lifecycle actions counter RwLock poisoned");
let entry = guard.entry(key).or_insert(0);
*entry = entry.saturating_add(1);
crate::metrics::record_lifecycle_action(bucket, label);
}
#[must_use]
pub fn actions_snapshot(&self) -> HashMap<(String, String), u64> {
self.actions_total
.read()
.expect("lifecycle actions counter RwLock poisoned")
.clone()
}
#[must_use]
pub fn buckets(&self) -> Vec<String> {
let map = self
.by_bucket
.read()
.expect("lifecycle state RwLock poisoned");
let mut out: Vec<String> = map.keys().cloned().collect();
out.sort();
out
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct EvaluateFlags {
pub is_noncurrent: bool,
pub now: Option<DateTime<Utc>>,
}
pub type EvaluateBatchEntry = (String, Duration, u64, Vec<(String, String)>);
#[must_use]
pub fn evaluate_batch(
manager: &LifecycleManager,
bucket: &str,
objects: &[EvaluateBatchEntry],
) -> Vec<(String, LifecycleAction)> {
let mut out = Vec::with_capacity(objects.len());
for (key, age, size, tags) in objects {
if let Some(action) = manager.evaluate(bucket, key, *age, *size, tags) {
out.push((key.clone(), action));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled(rule: LifecycleRule) -> LifecycleRule {
LifecycleRule {
status: LifecycleStatus::Enabled,
..rule
}
}
fn cfg_with(rules: Vec<LifecycleRule>) -> LifecycleConfig {
LifecycleConfig { rules }
}
fn manager_with(bucket: &str, rules: Vec<LifecycleRule>) -> LifecycleManager {
let m = LifecycleManager::new();
m.put(bucket, cfg_with(rules));
m
}
#[test]
fn evaluate_age_past_expiration_returns_expire() {
let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 30)]);
let action = m.evaluate("b", "k", Duration::days(31), 100, &[]);
assert_eq!(action, Some(LifecycleAction::Expire));
}
#[test]
fn evaluate_age_before_expiration_returns_none() {
let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 30)]);
let action = m.evaluate("b", "k", Duration::days(5), 100, &[]);
assert_eq!(action, None);
}
#[test]
fn evaluate_prefix_filter_matches() {
let mut rule = LifecycleRule::expire_after_days("r", 1);
rule.filter.prefix = Some("logs/".into());
let m = manager_with("b", vec![rule]);
assert_eq!(
m.evaluate("b", "logs/2026/a.log", Duration::days(2), 1, &[]),
Some(LifecycleAction::Expire)
);
assert_eq!(
m.evaluate("b", "data/keep.bin", Duration::days(2), 1, &[]),
None
);
}
#[test]
fn evaluate_tag_filter_requires_all_tags_to_match() {
let mut rule = LifecycleRule::expire_after_days("r", 1);
rule.filter.tags = vec![
("env".into(), "dev".into()),
("expirable".into(), "yes".into()),
];
let m = manager_with("b", vec![rule]);
assert_eq!(
m.evaluate(
"b",
"k",
Duration::days(2),
1,
&[
("env".into(), "dev".into()),
("expirable".into(), "yes".into()),
("owner".into(), "alice".into()),
]
),
Some(LifecycleAction::Expire)
);
assert_eq!(
m.evaluate(
"b",
"k",
Duration::days(2),
1,
&[("env".into(), "dev".into())]
),
None
);
assert_eq!(
m.evaluate(
"b",
"k",
Duration::days(2),
1,
&[
("env".into(), "prod".into()),
("expirable".into(), "yes".into()),
]
),
None
);
}
#[test]
fn evaluate_size_filters_gate_action() {
let mut rule = LifecycleRule::expire_after_days("r", 1);
rule.filter.object_size_greater_than = Some(1024);
rule.filter.object_size_less_than = Some(10 * 1024);
let m = manager_with("b", vec![rule]);
assert_eq!(
m.evaluate("b", "k", Duration::days(2), 4096, &[]),
Some(LifecycleAction::Expire)
);
assert_eq!(m.evaluate("b", "k", Duration::days(2), 1024, &[]), None);
assert_eq!(
m.evaluate("b", "k", Duration::days(2), 100 * 1024, &[]),
None
);
}
#[test]
fn evaluate_transition_fires_before_expiration() {
let rule = enabled(LifecycleRule {
id: "r".into(),
status: LifecycleStatus::Enabled,
filter: LifecycleFilter::default(),
expiration_days: Some(365),
expiration_date: None,
transitions: vec![TransitionRule {
days: 30,
storage_class: "GLACIER_IR".into(),
}],
noncurrent_version_expiration_days: None,
abort_incomplete_multipart_upload_days: None,
});
let m = manager_with("b", vec![rule]);
let action = m.evaluate("b", "k", Duration::days(60), 1, &[]);
assert_eq!(
action,
Some(LifecycleAction::Transition {
storage_class: "GLACIER_IR".into(),
})
);
}
#[test]
fn evaluate_expiration_wins_when_threshold_is_earlier_than_transition() {
let rule = enabled(LifecycleRule {
id: "r".into(),
status: LifecycleStatus::Enabled,
filter: LifecycleFilter::default(),
expiration_days: Some(30),
expiration_date: None,
transitions: vec![TransitionRule {
days: 90,
storage_class: "GLACIER".into(),
}],
noncurrent_version_expiration_days: None,
abort_incomplete_multipart_upload_days: None,
});
let m = manager_with("b", vec![rule]);
let action = m.evaluate("b", "k", Duration::days(100), 1, &[]);
assert_eq!(action, Some(LifecycleAction::Expire));
}
#[test]
fn evaluate_disabled_rule_never_fires() {
let mut rule = LifecycleRule::expire_after_days("r", 1);
rule.status = LifecycleStatus::Disabled;
let m = manager_with("b", vec![rule]);
assert_eq!(m.evaluate("b", "k", Duration::days(365), 1, &[]), None);
}
#[test]
fn evaluate_unknown_bucket_returns_none() {
let m = LifecycleManager::new();
assert_eq!(m.evaluate("ghost", "k", Duration::days(365), 1, &[]), None);
}
#[test]
fn evaluate_noncurrent_version_expiration() {
let rule = enabled(LifecycleRule {
id: "r".into(),
status: LifecycleStatus::Enabled,
filter: LifecycleFilter::default(),
expiration_days: None,
expiration_date: None,
transitions: vec![],
noncurrent_version_expiration_days: Some(7),
abort_incomplete_multipart_upload_days: None,
});
let m = manager_with("b", vec![rule]);
assert_eq!(m.evaluate("b", "k", Duration::days(30), 1, &[]), None);
let action = m.evaluate_with_flags(
"b",
"k",
Duration::days(8),
1,
&[],
EvaluateFlags {
is_noncurrent: true,
now: None,
},
);
assert_eq!(action, Some(LifecycleAction::Expire));
let action = m.evaluate_with_flags(
"b",
"k",
Duration::days(3),
1,
&[],
EvaluateFlags {
is_noncurrent: true,
now: None,
},
);
assert_eq!(action, None);
}
#[test]
fn evaluate_batch_distributes_actions_across_object_ages() {
let rule = enabled(LifecycleRule {
id: "r".into(),
status: LifecycleStatus::Enabled,
filter: LifecycleFilter::default(),
expiration_days: Some(60),
expiration_date: None,
transitions: vec![TransitionRule {
days: 30,
storage_class: "STANDARD_IA".into(),
}],
noncurrent_version_expiration_days: None,
abort_incomplete_multipart_upload_days: None,
});
let m = manager_with("b", vec![rule]);
let objects = vec![
("young".to_string(), Duration::days(10), 1u64, vec![]),
("middle".to_string(), Duration::days(40), 1u64, vec![]),
("middle2".to_string(), Duration::days(45), 1u64, vec![]),
("old".to_string(), Duration::days(90), 1u64, vec![]),
("ancient".to_string(), Duration::days(365), 1u64, vec![]),
];
let actions = evaluate_batch(&m, "b", &objects);
assert_eq!(actions.len(), 4);
for (_, a) in &actions {
assert!(matches!(a, LifecycleAction::Transition { .. }));
}
}
#[test]
fn json_round_trip_preserves_rules() {
let rule = enabled(LifecycleRule {
id: "complex".into(),
status: LifecycleStatus::Enabled,
filter: LifecycleFilter {
prefix: Some("logs/".into()),
tags: vec![("env".into(), "prod".into())],
object_size_greater_than: Some(1024),
object_size_less_than: None,
},
expiration_days: Some(365),
expiration_date: None,
transitions: vec![TransitionRule {
days: 30,
storage_class: "STANDARD_IA".into(),
}],
noncurrent_version_expiration_days: Some(7),
abort_incomplete_multipart_upload_days: Some(3),
});
let m = manager_with("b1", vec![rule.clone()]);
let json = m.to_json().expect("to_json");
let m2 = LifecycleManager::from_json(&json).expect("from_json");
let cfg = m2.get("b1").expect("bucket survives roundtrip");
assert_eq!(cfg.rules.len(), 1);
assert_eq!(cfg.rules[0], rule);
}
#[test]
fn lifecycle_config_default_is_empty() {
let cfg = LifecycleConfig::default();
assert!(cfg.rules.is_empty());
}
#[test]
fn evaluate_batch_skips_locked_objects_at_caller_layer() {
let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 1)]);
let objects = vec![
("locked".to_string(), Duration::days(30), 1u64, vec![]),
("free".to_string(), Duration::days(30), 1u64, vec![]),
];
let locked_keys: std::collections::HashSet<&str> = ["locked"].into_iter().collect();
let raw = evaluate_batch(&m, "b", &objects);
let filtered: Vec<_> = raw
.into_iter()
.filter(|(k, _)| !locked_keys.contains(k.as_str()))
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "free");
}
#[test]
fn record_action_bumps_per_bucket_counter() {
let m = LifecycleManager::new();
m.record_action("b", &LifecycleAction::Expire);
m.record_action("b", &LifecycleAction::Expire);
m.record_action(
"b",
&LifecycleAction::Transition {
storage_class: "GLACIER".into(),
},
);
let snap = m.actions_snapshot();
assert_eq!(snap.get(&("b".into(), "expire".into())).copied(), Some(2));
assert_eq!(
snap.get(&("b".into(), "transition".into())).copied(),
Some(1)
);
}
}