Skip to main content

s4_server/
lifecycle.rs

1//! S3 Lifecycle execution — per-bucket rule evaluation + manager skeleton
2//! (v0.6 #37).
3//!
4//! AWS S3 Lifecycle attaches a **list of rules** to a bucket; each rule may
5//! request that S3
6//!
7//! 1. **Expire** an object once its age (or the calendar date) crosses a
8//!    threshold (`Expiration { Days | Date }`),
9//! 2. **Transition** an object to a different storage class (`Transition
10//!    { Days, StorageClass }` — `STANDARD_IA`, `GLACIER_IR`, ...),
11//! 3. **Expire noncurrent versions** in a versioning-enabled bucket
12//!    (`NoncurrentVersionExpiration { NoncurrentDays }`).
13//!
14//! Until v0.6 #37 the matching `PutBucketLifecycleConfiguration` /
15//! `GetBucketLifecycleConfiguration` / `DeleteBucketLifecycle` handlers
16//! in `crates/s4-server/src/service.rs` were pure passthroughs (the s3s
17//! framework's default backend stored them but nothing read the rules).
18//! This module owns the in-memory configuration store + the rule
19//! evaluator that decides, for any single object, whether an action
20//! should fire **right now**.
21//!
22//! ## responsibilities (v0.6 #37)
23//!
24//! - in-memory `bucket -> LifecycleConfig` map with JSON snapshot
25//!   round-trip (mirroring `versioning.rs` / `object_lock.rs` /
26//!   `inventory.rs`'s shape so `--lifecycle-state-file` is a one-line
27//!   addition in `main.rs`).
28//! - per-bucket action counters (`actions_total`) — bumped by the
29//!   future scanner when an Expiration / Transition /
30//!   NoncurrentExpiration action is taken, surfaced via Prometheus
31//!   (`s4_lifecycle_actions_total`, see `metrics.rs`).
32//! - [`LifecycleManager::evaluate`] — given one (bucket, key, age,
33//!   size, tags) tuple, walk the bucket's rules in declaration order
34//!   and return the first matching action. Returns `None` when no
35//!   rule matches (or when the matching rule is `Disabled`).
36//! - [`evaluate_batch`] — batched form for the test path: walks a
37//!   slice of `(key, age, size, tags)` tuples and returns the (key,
38//!   action) pairs that should fire. The actual backend invocation
39//!   (S3.delete_object / metadata rewrite) is the caller's job.
40//!
41//! ## scope limitations (v0.6 #37)
42//!
43//! - **Background scanner is a skeleton only.** `main.rs`'s
44//!   `--lifecycle-scan-interval-hours` flag spawns a tokio task that
45//!   logs the bucket list and stamps a "would-have-run" marker;
46//!   walking the source bucket via `list_objects_v2` and actually
47//!   invoking `delete_object` / metadata rewrite for each evaluated
48//!   action is deferred to v0.7+. Wiring the scheduler to walk a real
49//!   bucket end-to-end requires a back-reference from the scheduler
50//!   into `S4Service` for the `list_objects_v2` walk and that
51//!   reshuffle is out of scope for this issue. The
52//!   [`crate::S4Service::run_lifecycle_once_for_test`] entry covers
53//!   the in-memory equivalent so the unit + E2E tests exercise the
54//!   evaluator end-to-end.
55//! - **`AbortIncompleteMultipartUpload`** is parsed and stored on the
56//!   `LifecycleRule` (so PutBucketLifecycleConfiguration round-trips
57//!   the field) but not enforced — multipart abort sweeping is a
58//!   separate scanner that lives next to the multipart upload manager
59//!   (v0.7+).
60//! - **`expiration_date` (calendar date)** is supported in the
61//!   evaluator: a rule with `expiration_date` past `now` fires
62//!   Expiration immediately. Same wire form as AWS S3.
63//! - **Multi-instance replication.** All state is single-instance
64//!   in-memory; `--lifecycle-state-file <PATH>` provides restart
65//!   recovery via JSON snapshot, matching the
66//!   `--versioning-state-file` shape.
67//! - **Object Lock interplay**: the evaluator does NOT consult the
68//!   `ObjectLockManager` directly (the evaluator API is
69//!   object-tags-and-size only); the scanner caller is expected to
70//!   skip locked objects — see the `evaluate_batch_skips_locked` test
71//!   for the canonical pattern. Locking always wins over Lifecycle.
72//! - **Versioning interplay**: the evaluator treats noncurrent
73//!   versions as a separate input — pass `is_noncurrent = true` to
74//!   [`LifecycleManager::evaluate_with_flags`] for noncurrent version
75//!   expiration matching. The legacy `evaluate` shorthand defaults
76//!   `is_noncurrent = false` (current version) so existing call sites
77//!   stay one-liners.
78
79use std::collections::HashMap;
80use std::sync::RwLock;
81
82use chrono::{DateTime, Duration, Utc};
83use serde::{Deserialize, Serialize};
84
85/// Whether a rule is currently being applied. Mirrors AWS S3
86/// `ExpirationStatus` (`"Enabled"` / `"Disabled"`).
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub enum LifecycleStatus {
89    Enabled,
90    Disabled,
91}
92
93impl LifecycleStatus {
94    /// Wire form used by AWS S3 (`"Enabled"` / `"Disabled"`).
95    #[must_use]
96    pub fn as_aws_str(self) -> &'static str {
97        match self {
98            Self::Enabled => "Enabled",
99            Self::Disabled => "Disabled",
100        }
101    }
102
103    /// Parse the AWS wire form (case-insensitive). Falls back to `Disabled`
104    /// on unrecognised input — this matches AWS conservative behaviour
105    /// (an unparseable status is treated as "off" so a typo doesn't silently
106    /// expire data).
107    #[must_use]
108    pub fn from_aws_str(s: &str) -> Self {
109        if s.eq_ignore_ascii_case("Enabled") {
110            Self::Enabled
111        } else {
112            Self::Disabled
113        }
114    }
115}
116
117/// Per-rule object filter. AWS S3 represents the filter as one of `Prefix`,
118/// `Tag`, `ObjectSizeGreaterThan`, `ObjectSizeLessThan`, or `And` (= AND of
119/// any subset of those predicates). For internal storage we flatten the
120/// "And" form into a struct of optional fields plus a vector of (key, value)
121/// tags — every present field must match (logical AND). An empty filter (all
122/// fields `None` / empty `tags`) matches every object in the bucket.
123#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub struct LifecycleFilter {
125    /// Object key prefix (empty / `None` = no prefix gating).
126    #[serde(default)]
127    pub prefix: Option<String>,
128    /// Logical AND across every entry: every (key, value) must match the
129    /// object's own tag set.
130    #[serde(default)]
131    pub tags: Vec<(String, String)>,
132    /// Object must be *strictly greater than* this size in bytes.
133    #[serde(default)]
134    pub object_size_greater_than: Option<u64>,
135    /// Object must be *strictly less than* this size in bytes.
136    #[serde(default)]
137    pub object_size_less_than: Option<u64>,
138}
139
140impl LifecycleFilter {
141    /// `true` when this filter accepts the candidate. Empty filter accepts
142    /// every object. Tag matching is AND of all listed tags (each present in
143    /// `object_tags` with the matching value).
144    #[must_use]
145    pub fn matches(&self, key: &str, size: u64, object_tags: &[(String, String)]) -> bool {
146        if let Some(p) = &self.prefix
147            && !key.starts_with(p)
148        {
149            return false;
150        }
151        if let Some(min) = self.object_size_greater_than
152            && size <= min
153        {
154            return false;
155        }
156        if let Some(max) = self.object_size_less_than
157            && size >= max
158        {
159            return false;
160        }
161        for (tk, tv) in &self.tags {
162            let matched = object_tags.iter().any(|(ok, ov)| ok == tk && ov == tv);
163            if !matched {
164                return false;
165            }
166        }
167        true
168    }
169}
170
171/// A single transition step (object age threshold + target storage class).
172/// `days` is days since the object was created. AWS S3 also accepts `Date`
173/// for transitions but Lifecycle deployments overwhelmingly use `Days`; the
174/// `Date` form is omitted here on purpose to keep the evaluator narrow
175/// (operators wanting calendar transitions can synthesise a one-shot rule
176/// at the cadence of their scanner).
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct TransitionRule {
179    pub days: u32,
180    /// Target storage class (`"STANDARD_IA"` / `"GLACIER_IR"` /
181    /// `"GLACIER"` / `"DEEP_ARCHIVE"` / `"INTELLIGENT_TIERING"` /
182    /// `"ONEZONE_IA"`). Stored as the AWS wire string so PutBucket /
183    /// GetBucket round-trip is a no-op.
184    pub storage_class: String,
185}
186
187/// One lifecycle rule. AWS S3's `LifecycleRule` flattened into the subset
188/// the v0.6 #37 evaluator handles. `id` is the operator-supplied label and
189/// makes Get / Put round-trips non-lossy.
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191pub struct LifecycleRule {
192    pub id: String,
193    pub status: LifecycleStatus,
194    #[serde(default)]
195    pub filter: LifecycleFilter,
196    /// Days since the object was created. Mutually exclusive with
197    /// [`Self::expiration_date`] in AWS — both fields are accepted here on
198    /// input (the evaluator picks `expiration_days` first, then
199    /// `expiration_date`) so a malformed rule with both set still evaluates
200    /// deterministically rather than silently dropping the action.
201    #[serde(default)]
202    pub expiration_days: Option<u32>,
203    /// Calendar date past which matching objects are expired (AWS wire form
204    /// is ISO 8601; here we keep it as a `DateTime<Utc>` so round-trips
205    /// through `serde_json` survive without re-parsing).
206    #[serde(default)]
207    pub expiration_date: Option<DateTime<Utc>>,
208    /// Transition steps in declaration order. The evaluator picks the
209    /// deepest transition (largest `days` ≤ object age) and resolves any
210    /// conflict with expiration in [`LifecycleManager::evaluate_with_flags`].
211    #[serde(default)]
212    pub transitions: Vec<TransitionRule>,
213    /// Days an object has been noncurrent before the noncurrent-version
214    /// expiration fires. Only consulted when the evaluator is asked about
215    /// a noncurrent object (`is_noncurrent = true`).
216    #[serde(default)]
217    pub noncurrent_version_expiration_days: Option<u32>,
218    /// Days after a multipart upload is initiated before the abort fires.
219    /// Stored so PutBucket round-trips, but **not enforced** in the
220    /// v0.6 #37 evaluator — multipart sweeping lives elsewhere.
221    #[serde(default)]
222    pub abort_incomplete_multipart_upload_days: Option<u32>,
223}
224
225impl LifecycleRule {
226    /// Convenience constructor for a "expire after N days" rule. Useful in
227    /// tests + operator scripts.
228    #[must_use]
229    pub fn expire_after_days(id: impl Into<String>, days: u32) -> Self {
230        Self {
231            id: id.into(),
232            status: LifecycleStatus::Enabled,
233            filter: LifecycleFilter::default(),
234            expiration_days: Some(days),
235            expiration_date: None,
236            transitions: Vec::new(),
237            noncurrent_version_expiration_days: None,
238            abort_incomplete_multipart_upload_days: None,
239        }
240    }
241}
242
243/// Per-bucket lifecycle configuration (ordered list of rules — first match
244/// wins, matching AWS S3 semantics).
245#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
246pub struct LifecycleConfig {
247    pub rules: Vec<LifecycleRule>,
248}
249
250/// The action a single rule wants to take **right now** for a candidate
251/// object.
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub enum LifecycleAction {
254    /// Delete the object (`Expiration` / `NoncurrentVersionExpiration`).
255    Expire,
256    /// Move the object to a different storage class (`Transition`). The
257    /// inner string is the AWS wire form (e.g. `"GLACIER_IR"`).
258    Transition { storage_class: String },
259}
260
261impl LifecycleAction {
262    /// Stable label suitable for a metric counter
263    /// (`s4_lifecycle_actions_total{action="..."}`).
264    #[must_use]
265    pub fn metric_label(&self) -> &'static str {
266        match self {
267            Self::Expire => "expire",
268            Self::Transition { .. } => "transition",
269        }
270    }
271}
272
273/// snapshot のシリアライズ format。`to_json` / `from_json` 用。
274#[derive(Debug, Default, Serialize, Deserialize)]
275struct LifecycleSnapshot {
276    by_bucket: HashMap<String, LifecycleConfig>,
277}
278
279/// Per-bucket lifecycle configuration manager.
280///
281/// All read / write operations go through `RwLock` for thread safety;
282/// clones are cheap (`Arc<LifecycleManager>` is the expected handle shape).
283/// `actions_total` is a parallel `RwLock<HashMap<...>>` of `(bucket,
284/// action_label) -> count` so the future background scanner can stamp
285/// successful actions and operators can `GET /metrics` to see the running
286/// totals (the metric is also surfaced via `metrics::counter!` — see
287/// [`crate::metrics::record_lifecycle_action`]).
288#[derive(Debug, Default)]
289pub struct LifecycleManager {
290    by_bucket: RwLock<HashMap<String, LifecycleConfig>>,
291    /// `(bucket, action_label) -> count`. Bumped by the scanner via
292    /// [`Self::record_action`]. Action labels are the
293    /// [`LifecycleAction::metric_label`] values
294    /// (`"expire"` / `"transition"`).
295    actions_total: RwLock<HashMap<(String, String), u64>>,
296}
297
298impl LifecycleManager {
299    /// Empty manager — no bucket has rules.
300    #[must_use]
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Replace (or create) the lifecycle configuration for `bucket`. Drops
306    /// any previously-attached rules in one shot — matches AWS S3
307    /// `PutBucketLifecycleConfiguration` (full replace, no merge).
308    pub fn put(&self, bucket: &str, config: LifecycleConfig) {
309        self.by_bucket
310            .write()
311            .expect("lifecycle state RwLock poisoned")
312            .insert(bucket.to_owned(), config);
313    }
314
315    /// Return a clone of the bucket's configuration, if any.
316    #[must_use]
317    pub fn get(&self, bucket: &str) -> Option<LifecycleConfig> {
318        self.by_bucket
319            .read()
320            .expect("lifecycle state RwLock poisoned")
321            .get(bucket)
322            .cloned()
323    }
324
325    /// Drop the bucket's lifecycle configuration (idempotent — missing
326    /// bucket is OK).
327    pub fn delete(&self, bucket: &str) {
328        self.by_bucket
329            .write()
330            .expect("lifecycle state RwLock poisoned")
331            .remove(bucket);
332    }
333
334    /// JSON snapshot for restart-recoverable state. Pair with
335    /// [`Self::from_json`].
336    pub fn to_json(&self) -> Result<String, serde_json::Error> {
337        let by_bucket = self
338            .by_bucket
339            .read()
340            .expect("lifecycle state RwLock poisoned")
341            .clone();
342        let snap = LifecycleSnapshot { by_bucket };
343        serde_json::to_string(&snap)
344    }
345
346    /// Restore from a JSON snapshot produced by [`Self::to_json`]. Action
347    /// counters are intentionally not snapshotted — they're transient
348    /// observability data and should reset across process restarts so
349    /// `rate(s4_lifecycle_actions_total[1h])` doesn't double-count.
350    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
351        let snap: LifecycleSnapshot = serde_json::from_str(s)?;
352        Ok(Self {
353            by_bucket: RwLock::new(snap.by_bucket),
354            actions_total: RwLock::new(HashMap::new()),
355        })
356    }
357
358    /// Evaluate which rule (if any) applies to a single **current-version**
359    /// object right now. Walks the bucket's rules in declaration order;
360    /// returns the first matching action. Returns `None` when no rule
361    /// matches (or when the matching rule is `Disabled`, or when the
362    /// bucket has no lifecycle configuration).
363    ///
364    /// Within a single rule the precedence is:
365    ///
366    /// 1. Pick the deepest transition whose `days` threshold is currently
367    ///    met (= largest `days ≤ object age`).
368    /// 2. Conflict with expiration: if `expiration_days <=
369    ///    transition_days` for the chosen transition, expiration wins
370    ///    (the rule wants the object gone before it would have been
371    ///    transitioned). Otherwise transition wins (e.g. transition at
372    ///    30d, expiration at 365d, age 60d → transition fires now,
373    ///    expiration is future).
374    /// 3. `expiration_date` matches when `now >= expiration_date` and no
375    ///    transition is currently applicable.
376    ///
377    /// `object_age` is "now - created_at" supplied by the caller — keeping
378    /// the evaluator pure of the wall clock makes deterministic testing
379    /// trivial.
380    #[must_use]
381    pub fn evaluate(
382        &self,
383        bucket: &str,
384        key: &str,
385        object_age: Duration,
386        object_size: u64,
387        object_tags: &[(String, String)],
388    ) -> Option<LifecycleAction> {
389        self.evaluate_with_flags(
390            bucket,
391            key,
392            object_age,
393            object_size,
394            object_tags,
395            EvaluateFlags::default(),
396        )
397    }
398
399    /// Full-form evaluator with flags for noncurrent-version handling.
400    /// Use this when the scanner is walking a versioning-enabled bucket;
401    /// pass `is_noncurrent = true` for entries that are not the latest
402    /// non-delete-marker version.
403    #[must_use]
404    pub fn evaluate_with_flags(
405        &self,
406        bucket: &str,
407        key: &str,
408        object_age: Duration,
409        object_size: u64,
410        object_tags: &[(String, String)],
411        flags: EvaluateFlags,
412    ) -> Option<LifecycleAction> {
413        let cfg = self.get(bucket)?;
414        let now_for_date = flags.now.unwrap_or_else(Utc::now);
415        let age_days = object_age.num_days().max(0);
416        let age_days_u32 = u32::try_from(age_days).unwrap_or(u32::MAX);
417        for rule in &cfg.rules {
418            if rule.status != LifecycleStatus::Enabled {
419                continue;
420            }
421            if !rule.filter.matches(key, object_size, object_tags) {
422                continue;
423            }
424            // Noncurrent-version expiration: only consulted when the
425            // caller explicitly flags this entry as noncurrent. The
426            // current-version expiration / transition rules do not fire
427            // for noncurrent versions in AWS S3 semantics.
428            if flags.is_noncurrent {
429                if let Some(days) = rule.noncurrent_version_expiration_days
430                    && age_days_u32 >= days
431                {
432                    return Some(LifecycleAction::Expire);
433                }
434                continue;
435            }
436            // Current-version path.
437            let exp_days_match = rule.expiration_days.filter(|d| age_days_u32 >= *d);
438            let exp_date_match = rule.expiration_date.filter(|d| now_for_date >= *d);
439            // Pick the deepest transition whose threshold is at or
440            // below the object's age. Transitions are typically
441            // declaration-ordered by ascending `days`, but we don't
442            // require it — taking the largest threshold means an
443            // object aged 90d gets `GLACIER` over `STANDARD_IA` even
444            // if `STANDARD_IA(30d)` was declared first.
445            let chosen_transition = rule
446                .transitions
447                .iter()
448                .filter(|t| age_days_u32 >= t.days)
449                .max_by_key(|t| t.days);
450            // Conflict resolution: when `expiration_days` fires AND a
451            // transition fires, expiration wins iff
452            // `expiration_days <= transition_days` (rule wants object
453            // gone before / at the same time it would have been
454            // transitioned). Otherwise the transition wins.
455            if let Some(exp_threshold) = exp_days_match {
456                let trans_threshold = chosen_transition.map(|t| t.days).unwrap_or(u32::MAX);
457                if exp_threshold <= trans_threshold {
458                    return Some(LifecycleAction::Expire);
459                }
460            }
461            if let Some(t) = chosen_transition {
462                return Some(LifecycleAction::Transition {
463                    storage_class: t.storage_class.clone(),
464                });
465            }
466            // Calendar-date expiration (no transition currently
467            // applicable, but the rule's expiration_date is past).
468            if exp_date_match.is_some() {
469                return Some(LifecycleAction::Expire);
470            }
471            // Fall through to the next rule when no action fires for
472            // this rule — first-match-wins applies only to *firing*
473            // rules, matching AWS semantics where overlapping rules
474            // with disjoint thresholds compose.
475        }
476        None
477    }
478
479    /// Stamp the per-bucket action counter and bump the matching
480    /// Prometheus counter. Called by the future scanner after a successful
481    /// delete / metadata rewrite.
482    pub fn record_action(&self, bucket: &str, action: &LifecycleAction) {
483        let label = action.metric_label();
484        let key = (bucket.to_owned(), label.to_owned());
485        let mut guard = self
486            .actions_total
487            .write()
488            .expect("lifecycle actions counter RwLock poisoned");
489        let entry = guard.entry(key).or_insert(0);
490        *entry = entry.saturating_add(1);
491        crate::metrics::record_lifecycle_action(bucket, label);
492    }
493
494    /// Read-only snapshot of the per-(bucket, action) counter map.
495    /// Useful for tests + introspection (`/admin/lifecycle/stats` style
496    /// endpoints in the future).
497    #[must_use]
498    pub fn actions_snapshot(&self) -> HashMap<(String, String), u64> {
499        self.actions_total
500            .read()
501            .expect("lifecycle actions counter RwLock poisoned")
502            .clone()
503    }
504
505    /// All buckets with a lifecycle configuration attached. Sorted for
506    /// stable scanner ordering.
507    #[must_use]
508    pub fn buckets(&self) -> Vec<String> {
509        let map = self
510            .by_bucket
511            .read()
512            .expect("lifecycle state RwLock poisoned");
513        let mut out: Vec<String> = map.keys().cloned().collect();
514        out.sort();
515        out
516    }
517}
518
519/// Flags for [`LifecycleManager::evaluate_with_flags`]. Default is
520/// "current-version object, evaluator picks `Utc::now()` for the date
521/// comparison". Tests override `now` for determinism.
522#[derive(Clone, Copy, Debug, Default)]
523pub struct EvaluateFlags {
524    pub is_noncurrent: bool,
525    pub now: Option<DateTime<Utc>>,
526}
527
528/// One object the evaluator considers in a batch:
529/// `(key, object_age, object_size, object_tags)`. Defined as a type alias
530/// so [`evaluate_batch`] / [`crate::S4Service::run_lifecycle_once_for_test`]
531/// don't trip clippy's `type-complexity` lint, and so callers building the
532/// list have a single canonical shape to reach for.
533pub type EvaluateBatchEntry = (String, Duration, u64, Vec<(String, String)>);
534
535/// Test-driven scan entry: walks a list of [`EvaluateBatchEntry`] tuples
536/// and produces (key, action) pairs for every object that should fire an
537/// action **right now**. The actual backend invocation (S3.delete_object /
538/// metadata rewrite) is the caller's job. Used by both unit tests and the
539/// E2E test in `tests/roundtrip.rs`; the future background scanner will
540/// reuse the same entry once the bucket-walk is wired through the backend.
541#[must_use]
542pub fn evaluate_batch(
543    manager: &LifecycleManager,
544    bucket: &str,
545    objects: &[EvaluateBatchEntry],
546) -> Vec<(String, LifecycleAction)> {
547    let mut out = Vec::with_capacity(objects.len());
548    for (key, age, size, tags) in objects {
549        if let Some(action) = manager.evaluate(bucket, key, *age, *size, tags) {
550            out.push((key.clone(), action));
551        }
552    }
553    out
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    fn enabled(rule: LifecycleRule) -> LifecycleRule {
561        LifecycleRule {
562            status: LifecycleStatus::Enabled,
563            ..rule
564        }
565    }
566
567    fn cfg_with(rules: Vec<LifecycleRule>) -> LifecycleConfig {
568        LifecycleConfig { rules }
569    }
570
571    fn manager_with(bucket: &str, rules: Vec<LifecycleRule>) -> LifecycleManager {
572        let m = LifecycleManager::new();
573        m.put(bucket, cfg_with(rules));
574        m
575    }
576
577    #[test]
578    fn evaluate_age_past_expiration_returns_expire() {
579        let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 30)]);
580        let action = m.evaluate("b", "k", Duration::days(31), 100, &[]);
581        assert_eq!(action, Some(LifecycleAction::Expire));
582    }
583
584    #[test]
585    fn evaluate_age_before_expiration_returns_none() {
586        let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 30)]);
587        let action = m.evaluate("b", "k", Duration::days(5), 100, &[]);
588        assert_eq!(action, None);
589    }
590
591    #[test]
592    fn evaluate_prefix_filter_matches() {
593        let mut rule = LifecycleRule::expire_after_days("r", 1);
594        rule.filter.prefix = Some("logs/".into());
595        let m = manager_with("b", vec![rule]);
596        assert_eq!(
597            m.evaluate("b", "logs/2026/a.log", Duration::days(2), 1, &[]),
598            Some(LifecycleAction::Expire)
599        );
600        assert_eq!(
601            m.evaluate("b", "data/keep.bin", Duration::days(2), 1, &[]),
602            None
603        );
604    }
605
606    #[test]
607    fn evaluate_tag_filter_requires_all_tags_to_match() {
608        let mut rule = LifecycleRule::expire_after_days("r", 1);
609        rule.filter.tags = vec![
610            ("env".into(), "dev".into()),
611            ("expirable".into(), "yes".into()),
612        ];
613        let m = manager_with("b", vec![rule]);
614        // All tags present + matching → fire.
615        assert_eq!(
616            m.evaluate(
617                "b",
618                "k",
619                Duration::days(2),
620                1,
621                &[
622                    ("env".into(), "dev".into()),
623                    ("expirable".into(), "yes".into()),
624                    ("owner".into(), "alice".into()),
625                ]
626            ),
627            Some(LifecycleAction::Expire)
628        );
629        // One tag missing → no fire.
630        assert_eq!(
631            m.evaluate(
632                "b",
633                "k",
634                Duration::days(2),
635                1,
636                &[("env".into(), "dev".into())]
637            ),
638            None
639        );
640        // Tag present but with the wrong value → no fire.
641        assert_eq!(
642            m.evaluate(
643                "b",
644                "k",
645                Duration::days(2),
646                1,
647                &[
648                    ("env".into(), "prod".into()),
649                    ("expirable".into(), "yes".into()),
650                ]
651            ),
652            None
653        );
654    }
655
656    #[test]
657    fn evaluate_size_filters_gate_action() {
658        let mut rule = LifecycleRule::expire_after_days("r", 1);
659        rule.filter.object_size_greater_than = Some(1024);
660        rule.filter.object_size_less_than = Some(10 * 1024);
661        let m = manager_with("b", vec![rule]);
662        // Inside the (1024, 10*1024) range → fire.
663        assert_eq!(
664            m.evaluate("b", "k", Duration::days(2), 4096, &[]),
665            Some(LifecycleAction::Expire)
666        );
667        // At the boundary (size == greater_than) → strict `>`, no fire.
668        assert_eq!(m.evaluate("b", "k", Duration::days(2), 1024, &[]), None);
669        // Above the upper bound → no fire.
670        assert_eq!(
671            m.evaluate("b", "k", Duration::days(2), 100 * 1024, &[]),
672            None
673        );
674    }
675
676    #[test]
677    fn evaluate_transition_fires_before_expiration() {
678        // Transition at 30d, expiration at 365d, age 60d → transition.
679        let rule = enabled(LifecycleRule {
680            id: "r".into(),
681            status: LifecycleStatus::Enabled,
682            filter: LifecycleFilter::default(),
683            expiration_days: Some(365),
684            expiration_date: None,
685            transitions: vec![TransitionRule {
686                days: 30,
687                storage_class: "GLACIER_IR".into(),
688            }],
689            noncurrent_version_expiration_days: None,
690            abort_incomplete_multipart_upload_days: None,
691        });
692        let m = manager_with("b", vec![rule]);
693        let action = m.evaluate("b", "k", Duration::days(60), 1, &[]);
694        assert_eq!(
695            action,
696            Some(LifecycleAction::Transition {
697                storage_class: "GLACIER_IR".into(),
698            })
699        );
700    }
701
702    #[test]
703    fn evaluate_expiration_wins_when_threshold_is_earlier_than_transition() {
704        // Expiration at 30d, transition at 90d, age 100d → expire (the
705        // rule wants the object gone *before* it would have transitioned).
706        let rule = enabled(LifecycleRule {
707            id: "r".into(),
708            status: LifecycleStatus::Enabled,
709            filter: LifecycleFilter::default(),
710            expiration_days: Some(30),
711            expiration_date: None,
712            transitions: vec![TransitionRule {
713                days: 90,
714                storage_class: "GLACIER".into(),
715            }],
716            noncurrent_version_expiration_days: None,
717            abort_incomplete_multipart_upload_days: None,
718        });
719        let m = manager_with("b", vec![rule]);
720        let action = m.evaluate("b", "k", Duration::days(100), 1, &[]);
721        assert_eq!(action, Some(LifecycleAction::Expire));
722    }
723
724    #[test]
725    fn evaluate_disabled_rule_never_fires() {
726        let mut rule = LifecycleRule::expire_after_days("r", 1);
727        rule.status = LifecycleStatus::Disabled;
728        let m = manager_with("b", vec![rule]);
729        assert_eq!(m.evaluate("b", "k", Duration::days(365), 1, &[]), None);
730    }
731
732    #[test]
733    fn evaluate_unknown_bucket_returns_none() {
734        let m = LifecycleManager::new();
735        assert_eq!(m.evaluate("ghost", "k", Duration::days(365), 1, &[]), None);
736    }
737
738    #[test]
739    fn evaluate_noncurrent_version_expiration() {
740        let rule = enabled(LifecycleRule {
741            id: "r".into(),
742            status: LifecycleStatus::Enabled,
743            filter: LifecycleFilter::default(),
744            expiration_days: None,
745            expiration_date: None,
746            transitions: vec![],
747            noncurrent_version_expiration_days: Some(7),
748            abort_incomplete_multipart_upload_days: None,
749        });
750        let m = manager_with("b", vec![rule]);
751        // current-version path → no rule matches (no expiration_days set).
752        assert_eq!(m.evaluate("b", "k", Duration::days(30), 1, &[]), None);
753        // noncurrent path with age past 7d → expire.
754        let action = m.evaluate_with_flags(
755            "b",
756            "k",
757            Duration::days(8),
758            1,
759            &[],
760            EvaluateFlags {
761                is_noncurrent: true,
762                now: None,
763            },
764        );
765        assert_eq!(action, Some(LifecycleAction::Expire));
766        // noncurrent path with age before 7d → no fire.
767        let action = m.evaluate_with_flags(
768            "b",
769            "k",
770            Duration::days(3),
771            1,
772            &[],
773            EvaluateFlags {
774                is_noncurrent: true,
775                now: None,
776            },
777        );
778        assert_eq!(action, None);
779    }
780
781    #[test]
782    fn evaluate_batch_distributes_actions_across_object_ages() {
783        // Transition at 30d, expiration at 60d. Conflict resolver picks
784        // expire iff `exp_days <= trans_days` for the chosen transition.
785        // With exp=60, trans=30: at age 40-59 the transition fires; at
786        // age >= 60 expiration wins (because exp_days=60 <= trans_days=30
787        // is false, so... wait — re-read: the resolver compares
788        // exp_threshold (60) vs trans_threshold (30) and triggers expire
789        // ONLY when 60 <= 30, which is false → transition keeps winning
790        // until both thresholds met but exp <= trans). For exp=60 trans=30
791        // pair, transition always wins regardless of age (rule pattern is
792        // "transition first, expire later" — the next scanner pass
793        // picks up the expiration). So expect 4 transitions.
794        let rule = enabled(LifecycleRule {
795            id: "r".into(),
796            status: LifecycleStatus::Enabled,
797            filter: LifecycleFilter::default(),
798            expiration_days: Some(60),
799            expiration_date: None,
800            transitions: vec![TransitionRule {
801                days: 30,
802                storage_class: "STANDARD_IA".into(),
803            }],
804            noncurrent_version_expiration_days: None,
805            abort_incomplete_multipart_upload_days: None,
806        });
807        let m = manager_with("b", vec![rule]);
808        let objects = vec![
809            ("young".to_string(), Duration::days(10), 1u64, vec![]),
810            ("middle".to_string(), Duration::days(40), 1u64, vec![]),
811            ("middle2".to_string(), Duration::days(45), 1u64, vec![]),
812            ("old".to_string(), Duration::days(90), 1u64, vec![]),
813            ("ancient".to_string(), Duration::days(365), 1u64, vec![]),
814        ];
815        let actions = evaluate_batch(&m, "b", &objects);
816        assert_eq!(actions.len(), 4);
817        for (_, a) in &actions {
818            assert!(matches!(a, LifecycleAction::Transition { .. }));
819        }
820    }
821
822    #[test]
823    fn json_round_trip_preserves_rules() {
824        let rule = enabled(LifecycleRule {
825            id: "complex".into(),
826            status: LifecycleStatus::Enabled,
827            filter: LifecycleFilter {
828                prefix: Some("logs/".into()),
829                tags: vec![("env".into(), "prod".into())],
830                object_size_greater_than: Some(1024),
831                object_size_less_than: None,
832            },
833            expiration_days: Some(365),
834            expiration_date: None,
835            transitions: vec![TransitionRule {
836                days: 30,
837                storage_class: "STANDARD_IA".into(),
838            }],
839            noncurrent_version_expiration_days: Some(7),
840            abort_incomplete_multipart_upload_days: Some(3),
841        });
842        let m = manager_with("b1", vec![rule.clone()]);
843        let json = m.to_json().expect("to_json");
844        let m2 = LifecycleManager::from_json(&json).expect("from_json");
845        let cfg = m2.get("b1").expect("bucket survives roundtrip");
846        assert_eq!(cfg.rules.len(), 1);
847        assert_eq!(cfg.rules[0], rule);
848    }
849
850    #[test]
851    fn lifecycle_config_default_is_empty() {
852        let cfg = LifecycleConfig::default();
853        assert!(cfg.rules.is_empty());
854    }
855
856    #[test]
857    fn evaluate_batch_skips_locked_objects_at_caller_layer() {
858        // The evaluator itself does not consult ObjectLock; the scanner
859        // (and tests) are expected to filter locked keys out before /
860        // after calling `evaluate_batch`. This test documents the
861        // canonical pattern.
862        let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 1)]);
863        let objects = vec![
864            ("locked".to_string(), Duration::days(30), 1u64, vec![]),
865            ("free".to_string(), Duration::days(30), 1u64, vec![]),
866        ];
867        let locked_keys: std::collections::HashSet<&str> = ["locked"].into_iter().collect();
868        let raw = evaluate_batch(&m, "b", &objects);
869        let filtered: Vec<_> = raw
870            .into_iter()
871            .filter(|(k, _)| !locked_keys.contains(k.as_str()))
872            .collect();
873        assert_eq!(filtered.len(), 1);
874        assert_eq!(filtered[0].0, "free");
875    }
876
877    #[test]
878    fn record_action_bumps_per_bucket_counter() {
879        let m = LifecycleManager::new();
880        m.record_action("b", &LifecycleAction::Expire);
881        m.record_action("b", &LifecycleAction::Expire);
882        m.record_action(
883            "b",
884            &LifecycleAction::Transition {
885                storage_class: "GLACIER".into(),
886            },
887        );
888        let snap = m.actions_snapshot();
889        assert_eq!(snap.get(&("b".into(), "expire".into())).copied(), Some(2));
890        assert_eq!(
891            snap.get(&("b".into(), "transition".into())).copied(),
892            Some(1)
893        );
894    }
895}