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