Skip to main content

perfgate_server/
cleanup.rs

1//! Background artifact cleanup for retention policy enforcement.
2//!
3//! When `--retention-days` is set to a non-zero value, a background task
4//! periodically scans the artifact store and removes objects older than
5//! the configured retention period.
6
7use crate::storage::ArtifactStore;
8use chrono::{Duration, Utc};
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11use tracing::{error, info, warn};
12
13/// Result of a cleanup operation.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CleanupResult {
16    /// Number of objects scanned.
17    pub scanned: u64,
18    /// Number of objects deleted.
19    pub deleted: u64,
20    /// Number of objects that failed to delete.
21    pub errors: u64,
22    /// The retention cutoff timestamp used.
23    pub cutoff: String,
24}
25
26/// Runs a single cleanup pass against the artifact store.
27///
28/// Deletes all objects whose `last_modified` timestamp is older than
29/// `retention_days` days from now.
30pub async fn run_cleanup(
31    store: &dyn ArtifactStore,
32    retention_days: u64,
33) -> Result<CleanupResult, String> {
34    let cutoff = Utc::now() - Duration::days(retention_days as i64);
35    info!(
36        retention_days = retention_days,
37        cutoff = %cutoff,
38        "Starting artifact cleanup"
39    );
40
41    let objects = store
42        .list(None)
43        .await
44        .map_err(|e| format!("Failed to list objects: {}", e))?;
45
46    let scanned = objects.len() as u64;
47    let mut deleted = 0u64;
48    let mut errors = 0u64;
49
50    for obj in &objects {
51        if obj.last_modified < cutoff {
52            match store.delete(&obj.path).await {
53                Ok(()) => {
54                    deleted += 1;
55                    info!(
56                        path = %obj.path,
57                        last_modified = %obj.last_modified,
58                        "Deleted expired artifact"
59                    );
60                }
61                Err(e) => {
62                    errors += 1;
63                    warn!(
64                        path = %obj.path,
65                        error = %e,
66                        "Failed to delete expired artifact"
67                    );
68                }
69            }
70        }
71    }
72
73    let result = CleanupResult {
74        scanned,
75        deleted,
76        errors,
77        cutoff: cutoff.to_rfc3339(),
78    };
79
80    info!(
81        scanned = result.scanned,
82        deleted = result.deleted,
83        errors = result.errors,
84        "Artifact cleanup completed"
85    );
86
87    Ok(result)
88}
89
90/// Spawns a background task that runs cleanup periodically.
91///
92/// The task runs every `interval_hours` hours. It is designed to be
93/// cancelled via the tokio cancellation token / task abort.
94pub fn spawn_cleanup_task(
95    store: Arc<dyn ArtifactStore>,
96    retention_days: u64,
97    interval_hours: u64,
98) -> tokio::task::JoinHandle<()> {
99    tokio::spawn(async move {
100        let interval = std::time::Duration::from_secs(interval_hours * 3600);
101        info!(
102            retention_days = retention_days,
103            interval_hours = interval_hours,
104            "Background cleanup task started"
105        );
106
107        loop {
108            tokio::time::sleep(interval).await;
109
110            match run_cleanup(store.as_ref(), retention_days).await {
111                Ok(result) => {
112                    info!(
113                        deleted = result.deleted,
114                        scanned = result.scanned,
115                        "Background cleanup pass completed"
116                    );
117                }
118                Err(e) => {
119                    error!(error = %e, "Background cleanup pass failed");
120                }
121            }
122        }
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::error::StoreError;
130    use crate::storage::ArtifactMeta;
131    use async_trait::async_trait;
132    use chrono::{Duration, Utc};
133    use std::sync::Mutex;
134
135    /// A simple in-memory artifact store for testing cleanup logic.
136    #[derive(Debug)]
137    struct MockArtifactStore {
138        objects: Mutex<Vec<ArtifactMeta>>,
139        deleted: Mutex<Vec<String>>,
140    }
141
142    impl MockArtifactStore {
143        fn new(objects: Vec<ArtifactMeta>) -> Self {
144            Self {
145                objects: Mutex::new(objects),
146                deleted: Mutex::new(Vec::new()),
147            }
148        }
149
150        fn deleted_paths(&self) -> Vec<String> {
151            self.deleted.lock().unwrap().clone()
152        }
153    }
154
155    #[async_trait]
156    impl ArtifactStore for MockArtifactStore {
157        async fn put(&self, _path: &str, _data: Vec<u8>) -> Result<(), StoreError> {
158            Ok(())
159        }
160
161        async fn get(&self, _path: &str) -> Result<Vec<u8>, StoreError> {
162            Ok(vec![])
163        }
164
165        async fn delete(&self, path: &str) -> Result<(), StoreError> {
166            let mut deleted = self.deleted.lock().unwrap();
167            deleted.push(path.to_string());
168            Ok(())
169        }
170
171        async fn list(&self, _prefix: Option<&str>) -> Result<Vec<ArtifactMeta>, StoreError> {
172            let objects = self.objects.lock().unwrap();
173            Ok(objects.clone())
174        }
175    }
176
177    #[tokio::test]
178    async fn test_cleanup_deletes_expired_objects() {
179        let now = Utc::now();
180        let objects = vec![
181            ArtifactMeta {
182                path: "old-receipt.json".to_string(),
183                last_modified: now - Duration::days(10),
184                size: 1024,
185            },
186            ArtifactMeta {
187                path: "recent-receipt.json".to_string(),
188                last_modified: now - Duration::days(1),
189                size: 2048,
190            },
191            ArtifactMeta {
192                path: "ancient-receipt.json".to_string(),
193                last_modified: now - Duration::days(100),
194                size: 512,
195            },
196        ];
197
198        let store = MockArtifactStore::new(objects);
199        let result = run_cleanup(&store, 7).await.unwrap();
200
201        assert_eq!(result.scanned, 3);
202        assert_eq!(result.deleted, 2);
203        assert_eq!(result.errors, 0);
204
205        let deleted = store.deleted_paths();
206        assert!(deleted.contains(&"old-receipt.json".to_string()));
207        assert!(deleted.contains(&"ancient-receipt.json".to_string()));
208        assert!(!deleted.contains(&"recent-receipt.json".to_string()));
209    }
210
211    #[tokio::test]
212    async fn test_cleanup_no_expired_objects() {
213        let now = Utc::now();
214        let objects = vec![ArtifactMeta {
215            path: "fresh.json".to_string(),
216            last_modified: now - Duration::hours(1),
217            size: 100,
218        }];
219
220        let store = MockArtifactStore::new(objects);
221        let result = run_cleanup(&store, 30).await.unwrap();
222
223        assert_eq!(result.scanned, 1);
224        assert_eq!(result.deleted, 0);
225        assert_eq!(result.errors, 0);
226    }
227
228    #[tokio::test]
229    async fn test_cleanup_empty_store() {
230        let store = MockArtifactStore::new(vec![]);
231        let result = run_cleanup(&store, 7).await.unwrap();
232
233        assert_eq!(result.scanned, 0);
234        assert_eq!(result.deleted, 0);
235        assert_eq!(result.errors, 0);
236    }
237
238    /// Test that delete errors are counted but don't abort the scan.
239    #[tokio::test]
240    async fn test_cleanup_handles_delete_errors() {
241        /// A store that fails on delete for a specific path.
242        #[derive(Debug)]
243        struct FailingDeleteStore;
244
245        #[async_trait]
246        impl ArtifactStore for FailingDeleteStore {
247            async fn put(&self, _path: &str, _data: Vec<u8>) -> Result<(), StoreError> {
248                Ok(())
249            }
250            async fn get(&self, _path: &str) -> Result<Vec<u8>, StoreError> {
251                Ok(vec![])
252            }
253            async fn delete(&self, _path: &str) -> Result<(), StoreError> {
254                Err(StoreError::Other("permission denied".to_string()))
255            }
256            async fn list(&self, _prefix: Option<&str>) -> Result<Vec<ArtifactMeta>, StoreError> {
257                Ok(vec![ArtifactMeta {
258                    path: "locked.json".to_string(),
259                    last_modified: Utc::now() - Duration::days(30),
260                    size: 100,
261                }])
262            }
263        }
264
265        let store = FailingDeleteStore;
266        let result = run_cleanup(&store, 7).await.unwrap();
267
268        assert_eq!(result.scanned, 1);
269        assert_eq!(result.deleted, 0);
270        assert_eq!(result.errors, 1);
271    }
272}