Skip to main content

fakecloud_s3/
simulation.rs

1use crate::lifecycle::LifecycleProcessor;
2use crate::state::SharedS3State;
3
4/// Result of a lifecycle processor tick.
5pub struct LifecycleTickResult {
6    pub processed_buckets: u64,
7    pub expired_objects: u64,
8    pub transitioned_objects: u64,
9}
10
11/// Snapshot of a bucket's objects before processing.
12struct BucketSnapshot {
13    name: String,
14    object_count: usize,
15    storage_classes: Vec<(String, String)>,
16}
17
18/// Run one tick of the S3 lifecycle processor and return statistics.
19pub fn tick_lifecycle(state: &SharedS3State) -> LifecycleTickResult {
20    // Snapshot object counts and storage classes before processing (all accounts)
21    let (buckets_with_lifecycle, before_snapshot) = {
22        let __mas = state.read();
23        let mut count = 0u64;
24        let mut snapshot: Vec<BucketSnapshot> = Vec::new();
25        for (_, s) in __mas.iter() {
26            for bucket in s.buckets.values() {
27                let classes: Vec<(String, String)> = bucket
28                    .objects
29                    .iter()
30                    .map(|(k, o)| (k.clone(), o.storage_class.clone()))
31                    .collect();
32                snapshot.push(BucketSnapshot {
33                    name: bucket.name.clone(),
34                    object_count: bucket.objects.len(),
35                    storage_classes: classes,
36                });
37                if bucket.lifecycle_config.is_some() {
38                    count += 1;
39                }
40            }
41        }
42        (count, snapshot)
43    };
44
45    // Run the processor tick
46    let processor = LifecycleProcessor::new(state.clone());
47    processor.tick();
48
49    // Compute diffs
50    let mut expired_objects = 0u64;
51    let mut transitioned_objects = 0u64;
52
53    let __mas = state.read();
54    for snap in &before_snapshot {
55        // Find the bucket across all accounts
56        let bucket = __mas.iter().find_map(|(_, s)| s.buckets.get(&snap.name));
57        let bucket = match bucket {
58            Some(b) => b,
59            None => continue,
60        };
61
62        // Count expired (deleted) objects
63        let after_count = bucket.objects.len();
64        if snap.object_count > after_count {
65            expired_objects += (snap.object_count - after_count) as u64;
66        }
67
68        // Count transitioned objects (storage class changed)
69        for (key, old_class) in &snap.storage_classes {
70            if let Some(obj) = bucket.objects.get(key) {
71                if &obj.storage_class != old_class {
72                    transitioned_objects += 1;
73                }
74            }
75        }
76    }
77
78    LifecycleTickResult {
79        processed_buckets: buckets_with_lifecycle,
80        expired_objects,
81        transitioned_objects,
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::state::{S3Bucket, S3Object};
89    use bytes::Bytes;
90    use chrono::{Duration, Utc};
91    use parking_lot::RwLock;
92    use std::sync::Arc;
93
94    fn make_state() -> SharedS3State {
95        Arc::new(RwLock::new(
96            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
97        ))
98    }
99
100    fn make_object(key: &str, age_days: i64) -> S3Object {
101        S3Object {
102            key: key.to_string(),
103            body: crate::state::memory_body(Bytes::from("test")),
104            content_type: "application/octet-stream".to_string(),
105            etag: "\"abc\"".to_string(),
106            size: 4,
107            last_modified: Utc::now() - Duration::days(age_days),
108            storage_class: "STANDARD".to_string(),
109            ..Default::default()
110        }
111    }
112
113    #[test]
114    fn tick_lifecycle_expires_objects() {
115        let state = make_state();
116
117        {
118            let mut __mas = state.write();
119            let s = __mas.default_mut();
120            let mut bucket = S3Bucket::new("test-bucket", "us-east-1", "123456789012");
121            bucket.lifecycle_config = Some(
122                r#"<LifecycleConfiguration>
123                    <Rule>
124                        <Filter><Prefix></Prefix></Filter>
125                        <Status>Enabled</Status>
126                        <Expiration><Days>1</Days></Expiration>
127                    </Rule>
128                </LifecycleConfiguration>"#
129                    .to_string(),
130            );
131            bucket
132                .objects
133                .insert("old-file.txt".to_string(), make_object("old-file.txt", 5));
134            bucket
135                .objects
136                .insert("new-file.txt".to_string(), make_object("new-file.txt", 0));
137            s.buckets.insert("test-bucket".to_string(), bucket);
138        }
139
140        let result = tick_lifecycle(&state);
141        assert_eq!(result.processed_buckets, 1);
142        assert_eq!(result.expired_objects, 1);
143        assert_eq!(result.transitioned_objects, 0);
144
145        let __mas = state.read();
146        let s = __mas.default_ref();
147        let bucket = s.buckets.get("test-bucket").unwrap();
148        assert_eq!(bucket.objects.len(), 1);
149        assert!(bucket.objects.contains_key("new-file.txt"));
150    }
151
152    #[test]
153    fn tick_lifecycle_transitions_objects() {
154        let state = make_state();
155
156        {
157            let mut __mas = state.write();
158            let s = __mas.default_mut();
159            let mut bucket = S3Bucket::new("trans-bucket", "us-east-1", "123456789012");
160            bucket.lifecycle_config = Some(
161                r#"<LifecycleConfiguration>
162                    <Rule>
163                        <Filter><Prefix></Prefix></Filter>
164                        <Status>Enabled</Status>
165                        <Transition>
166                            <Days>1</Days>
167                            <StorageClass>GLACIER</StorageClass>
168                        </Transition>
169                    </Rule>
170                </LifecycleConfiguration>"#
171                    .to_string(),
172            );
173            bucket
174                .objects
175                .insert("old-file.txt".to_string(), make_object("old-file.txt", 5));
176            s.buckets.insert("trans-bucket".to_string(), bucket);
177        }
178
179        let result = tick_lifecycle(&state);
180        assert_eq!(result.processed_buckets, 1);
181        assert_eq!(result.expired_objects, 0);
182        assert_eq!(result.transitioned_objects, 1);
183
184        let __mas = state.read();
185        let s = __mas.default_ref();
186        let obj = s.buckets["trans-bucket"]
187            .objects
188            .get("old-file.txt")
189            .unwrap();
190        assert_eq!(obj.storage_class, "GLACIER");
191    }
192
193    #[test]
194    fn tick_lifecycle_no_config_returns_zero() {
195        let state = make_state();
196
197        {
198            let mut __mas = state.write();
199            let s = __mas.default_mut();
200            let bucket = S3Bucket::new("empty-bucket", "us-east-1", "123456789012");
201            s.buckets.insert("empty-bucket".to_string(), bucket);
202        }
203
204        let result = tick_lifecycle(&state);
205        assert_eq!(result.processed_buckets, 0);
206        assert_eq!(result.expired_objects, 0);
207        assert_eq!(result.transitioned_objects, 0);
208    }
209}