use crate::storage::ArtifactStore;
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CleanupResult {
pub scanned: u64,
pub deleted: u64,
pub errors: u64,
pub cutoff: String,
}
pub async fn run_cleanup(
store: &dyn ArtifactStore,
retention_days: u64,
) -> Result<CleanupResult, String> {
let cutoff = Utc::now() - Duration::days(retention_days as i64);
info!(
retention_days = retention_days,
cutoff = %cutoff,
"Starting artifact cleanup"
);
let objects = store
.list(None)
.await
.map_err(|e| format!("Failed to list objects: {}", e))?;
let scanned = objects.len() as u64;
let mut deleted = 0u64;
let mut errors = 0u64;
for obj in &objects {
if obj.last_modified < cutoff {
match store.delete(&obj.path).await {
Ok(()) => {
deleted += 1;
info!(
path = %obj.path,
last_modified = %obj.last_modified,
"Deleted expired artifact"
);
}
Err(e) => {
errors += 1;
warn!(
path = %obj.path,
error = %e,
"Failed to delete expired artifact"
);
}
}
}
}
let result = CleanupResult {
scanned,
deleted,
errors,
cutoff: cutoff.to_rfc3339(),
};
info!(
scanned = result.scanned,
deleted = result.deleted,
errors = result.errors,
"Artifact cleanup completed"
);
Ok(result)
}
pub fn spawn_cleanup_task(
store: Arc<dyn ArtifactStore>,
retention_days: u64,
interval_hours: u64,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let interval = std::time::Duration::from_secs(interval_hours * 3600);
info!(
retention_days = retention_days,
interval_hours = interval_hours,
"Background cleanup task started"
);
loop {
tokio::time::sleep(interval).await;
match run_cleanup(store.as_ref(), retention_days).await {
Ok(result) => {
info!(
deleted = result.deleted,
scanned = result.scanned,
"Background cleanup pass completed"
);
}
Err(e) => {
error!(error = %e, "Background cleanup pass failed");
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::StoreError;
use crate::storage::ArtifactMeta;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use std::sync::Mutex;
#[derive(Debug)]
struct MockArtifactStore {
objects: Mutex<Vec<ArtifactMeta>>,
deleted: Mutex<Vec<String>>,
}
impl MockArtifactStore {
fn new(objects: Vec<ArtifactMeta>) -> Self {
Self {
objects: Mutex::new(objects),
deleted: Mutex::new(Vec::new()),
}
}
fn deleted_paths(&self) -> Vec<String> {
self.deleted.lock().unwrap().clone()
}
}
#[async_trait]
impl ArtifactStore for MockArtifactStore {
async fn put(&self, _path: &str, _data: Vec<u8>) -> Result<(), StoreError> {
Ok(())
}
async fn get(&self, _path: &str) -> Result<Vec<u8>, StoreError> {
Ok(vec![])
}
async fn delete(&self, path: &str) -> Result<(), StoreError> {
let mut deleted = self.deleted.lock().unwrap();
deleted.push(path.to_string());
Ok(())
}
async fn list(&self, _prefix: Option<&str>) -> Result<Vec<ArtifactMeta>, StoreError> {
let objects = self.objects.lock().unwrap();
Ok(objects.clone())
}
}
#[tokio::test]
async fn test_cleanup_deletes_expired_objects() {
let now = Utc::now();
let objects = vec![
ArtifactMeta {
path: "old-receipt.json".to_string(),
last_modified: now - Duration::days(10),
size: 1024,
},
ArtifactMeta {
path: "recent-receipt.json".to_string(),
last_modified: now - Duration::days(1),
size: 2048,
},
ArtifactMeta {
path: "ancient-receipt.json".to_string(),
last_modified: now - Duration::days(100),
size: 512,
},
];
let store = MockArtifactStore::new(objects);
let result = run_cleanup(&store, 7).await.unwrap();
assert_eq!(result.scanned, 3);
assert_eq!(result.deleted, 2);
assert_eq!(result.errors, 0);
let deleted = store.deleted_paths();
assert!(deleted.contains(&"old-receipt.json".to_string()));
assert!(deleted.contains(&"ancient-receipt.json".to_string()));
assert!(!deleted.contains(&"recent-receipt.json".to_string()));
}
#[tokio::test]
async fn test_cleanup_no_expired_objects() {
let now = Utc::now();
let objects = vec![ArtifactMeta {
path: "fresh.json".to_string(),
last_modified: now - Duration::hours(1),
size: 100,
}];
let store = MockArtifactStore::new(objects);
let result = run_cleanup(&store, 30).await.unwrap();
assert_eq!(result.scanned, 1);
assert_eq!(result.deleted, 0);
assert_eq!(result.errors, 0);
}
#[tokio::test]
async fn test_cleanup_empty_store() {
let store = MockArtifactStore::new(vec![]);
let result = run_cleanup(&store, 7).await.unwrap();
assert_eq!(result.scanned, 0);
assert_eq!(result.deleted, 0);
assert_eq!(result.errors, 0);
}
#[tokio::test]
async fn test_cleanup_handles_delete_errors() {
#[derive(Debug)]
struct FailingDeleteStore;
#[async_trait]
impl ArtifactStore for FailingDeleteStore {
async fn put(&self, _path: &str, _data: Vec<u8>) -> Result<(), StoreError> {
Ok(())
}
async fn get(&self, _path: &str) -> Result<Vec<u8>, StoreError> {
Ok(vec![])
}
async fn delete(&self, _path: &str) -> Result<(), StoreError> {
Err(StoreError::Other("permission denied".to_string()))
}
async fn list(&self, _prefix: Option<&str>) -> Result<Vec<ArtifactMeta>, StoreError> {
Ok(vec![ArtifactMeta {
path: "locked.json".to_string(),
last_modified: Utc::now() - Duration::days(30),
size: 100,
}])
}
}
let store = FailingDeleteStore;
let result = run_cleanup(&store, 7).await.unwrap();
assert_eq!(result.scanned, 1);
assert_eq!(result.deleted, 0);
assert_eq!(result.errors, 1);
}
}