use std::collections::HashSet;
use std::path::Path;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::blob_store::BlobStore;
use crate::error::ArtifactError;
#[derive(Debug, Clone, Default)]
pub struct GcReport {
pub scanned: u64,
pub orphans_found: u64,
pub deleted: u64,
pub bytes_freed: u64,
pub skipped_grace_period: u64,
pub errors: u64,
}
#[derive(Debug, Clone)]
pub struct GcConfig {
pub grace_period: Duration,
pub dry_run: bool,
pub prefix: String,
}
impl Default for GcConfig {
fn default() -> Self {
Self {
grace_period: Duration::from_secs(7 * 86400),
dry_run: false,
prefix: "artifacts/".to_string(),
}
}
}
pub trait BlobTimestamp: Send + Sync {
fn created_at(
&self,
key: &str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<DateTime<Utc>, ArtifactError>> + Send + '_>,
>;
}
pub struct LocalBlobTimestamp {
root: std::path::PathBuf,
}
impl LocalBlobTimestamp {
pub fn new(root: std::path::PathBuf) -> Self {
Self { root }
}
}
impl BlobTimestamp for LocalBlobTimestamp {
fn created_at(
&self,
key: &str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<DateTime<Utc>, ArtifactError>> + Send + '_>,
> {
let path = self.root.join(key);
Box::pin(async move { local_mtime(&path) })
}
}
fn local_mtime(path: &Path) -> Result<DateTime<Utc>, ArtifactError> {
let meta = std::fs::metadata(path).map_err(|err| match err.kind() {
std::io::ErrorKind::NotFound => ArtifactError::NotFound(path.display().to_string()),
_ => ArtifactError::from(err),
})?;
let modified = meta.modified().map_err(|err| {
ArtifactError::Io(format!("failed to read mtime of {}: {err}", path.display()))
})?;
Ok(DateTime::<Utc>::from(modified))
}
pub async fn collect_garbage(
store: &dyn BlobStore,
timestamps: &dyn BlobTimestamp,
referenced_keys: &HashSet<String>,
config: &GcConfig,
) -> Result<GcReport, ArtifactError> {
let all_keys = store.list_keys(&config.prefix).await?;
let now = Utc::now();
let mut report = GcReport {
scanned: all_keys.len() as u64,
..Default::default()
};
for key in &all_keys {
if referenced_keys.contains(key) {
continue;
}
report.orphans_found += 1;
match timestamps.created_at(key).await {
Ok(created) => {
let age = now.signed_duration_since(created);
if age < chrono::Duration::from_std(config.grace_period).unwrap_or_default() {
report.skipped_grace_period += 1;
continue;
}
}
Err(ArtifactError::NotFound(_)) => continue,
Err(_) => {
report.skipped_grace_period += 1;
continue;
}
}
if config.dry_run {
report.deleted += 1;
continue;
}
match store.delete(key).await {
Ok(true) => report.deleted += 1,
Ok(false) => {}
Err(_) => report.errors += 1,
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::time::Duration;
use tempfile::TempDir;
use crate::blob_store::BlobStore;
use crate::local::LocalBlobStore;
use crate::stream_from_bytes;
use super::*;
fn store_and_ts() -> (TempDir, LocalBlobStore, LocalBlobTimestamp) {
let dir = TempDir::new().expect("temp dir");
let store = LocalBlobStore::new(dir.path());
let ts = LocalBlobTimestamp::new(dir.path().to_path_buf());
(dir, store, ts)
}
#[tokio::test]
async fn gc_deletes_orphan_blob() {
let (_dir, store, ts) = store_and_ts();
store
.put("artifacts/a/b/orphan", stream_from_bytes(b"dead".to_vec()))
.await
.expect("put");
let referenced = HashSet::new();
let config = GcConfig {
grace_period: Duration::ZERO,
dry_run: false,
prefix: "artifacts/".to_string(),
};
let report = collect_garbage(&store, &ts, &referenced, &config)
.await
.expect("gc");
assert_eq!(report.scanned, 1);
assert_eq!(report.orphans_found, 1);
assert_eq!(report.deleted, 1);
let keys = store.list_keys("artifacts/").await.expect("list");
assert!(keys.is_empty(), "orphan should be deleted");
}
#[tokio::test]
async fn gc_does_not_delete_referenced_blob() {
let (_dir, store, ts) = store_and_ts();
let key = "artifacts/a/b/referenced";
store
.put(key, stream_from_bytes(b"alive".to_vec()))
.await
.expect("put");
let mut referenced = HashSet::new();
referenced.insert(key.to_string());
let config = GcConfig {
grace_period: Duration::ZERO,
dry_run: false,
prefix: "artifacts/".to_string(),
};
let report = collect_garbage(&store, &ts, &referenced, &config)
.await
.expect("gc");
assert_eq!(report.scanned, 1);
assert_eq!(report.orphans_found, 0);
assert_eq!(report.deleted, 0);
let keys = store.list_keys("artifacts/").await.expect("list");
assert_eq!(keys.len(), 1, "referenced blob should survive");
}
#[tokio::test]
async fn gc_dry_run_does_not_delete() {
let (_dir, store, ts) = store_and_ts();
store
.put("artifacts/a/b/orphan", stream_from_bytes(b"data".to_vec()))
.await
.expect("put");
let referenced = HashSet::new();
let config = GcConfig {
grace_period: Duration::ZERO,
dry_run: true,
prefix: "artifacts/".to_string(),
};
let report = collect_garbage(&store, &ts, &referenced, &config)
.await
.expect("gc");
assert_eq!(report.orphans_found, 1);
assert_eq!(report.deleted, 1);
let keys = store.list_keys("artifacts/").await.expect("list");
assert_eq!(keys.len(), 1, "dry-run should not delete");
}
#[tokio::test]
async fn gc_respects_grace_period() {
let (_dir, store, ts) = store_and_ts();
store
.put("artifacts/a/b/recent", stream_from_bytes(b"new".to_vec()))
.await
.expect("put");
let referenced = HashSet::new();
let config = GcConfig {
grace_period: Duration::from_secs(86400),
dry_run: false,
prefix: "artifacts/".to_string(),
};
let report = collect_garbage(&store, &ts, &referenced, &config)
.await
.expect("gc");
assert_eq!(report.orphans_found, 1);
assert_eq!(report.skipped_grace_period, 1);
assert_eq!(report.deleted, 0);
let keys = store.list_keys("artifacts/").await.expect("list");
assert_eq!(keys.len(), 1, "recent orphan should survive grace period");
}
}