1use crate::storage::ArtifactStore;
8use chrono::{Duration, Utc};
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11use tracing::{error, info, warn};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CleanupResult {
16 pub scanned: u64,
18 pub deleted: u64,
20 pub errors: u64,
22 pub cutoff: String,
24}
25
26pub 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
90pub 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 #[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 #[tokio::test]
240 async fn test_cleanup_handles_delete_errors() {
241 #[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}