use std::{
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
branch::store::BranchStore,
config::BranchConfig,
error::{BranchError, BranchResult},
types::BranchStatus,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct GcReport {
pub branches_purged: u32,
pub bytes_freed: u64,
pub duration_ms: u64,
}
#[derive(Clone)]
pub struct SnapshotGc {
config: Arc<BranchConfig>,
registry: Arc<BranchStore>,
}
impl SnapshotGc {
pub fn new(config: Arc<BranchConfig>, registry: Arc<BranchStore>) -> Self {
Self { config, registry }
}
pub async fn run(&self) -> BranchResult<GcReport> {
let started_at = Instant::now();
let mut report = GcReport::default();
let branches = self.registry.list(self.config.workspace_id, None).await?;
let cutoff =
Utc::now() - chrono::Duration::seconds(self.config.gc_orphan_threshold_secs as i64);
for branch in branches {
let should_purge = match branch.status {
BranchStatus::Discarded { discarded_at } => discarded_at < cutoff,
BranchStatus::Orphan => branch.updated_at < cutoff,
_ => false,
};
if !should_purge {
continue;
}
report.bytes_freed += file_size(&branch.db_path).await.unwrap_or_default();
let sidecar = branch.db_path.with_extension("hash");
report.bytes_freed += file_size(&sidecar).await.unwrap_or_default();
let _ = tokio::fs::remove_file(&branch.db_path).await;
let _ = tokio::fs::remove_file(&sidecar).await;
if let Some(parent) = branch.db_path.parent() {
let _ = tokio::fs::remove_dir_all(parent).await;
}
self.registry
.update_status(branch.id, BranchStatus::Purged)
.await?;
report.branches_purged += 1;
}
report.duration_ms = started_at.elapsed().as_millis() as u64;
Ok(report)
}
pub async fn collect_orphans(&self) -> BranchResult<Vec<PathBuf>> {
let mut paths = Vec::new();
let mut entries = match tokio::fs::read_dir(&self.config.branches_dir).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(paths),
Err(error) => return Err(error.into()),
};
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let file_type = entry.file_type().await?;
if !file_type.is_dir() {
continue;
}
match parse_branch_dir_uuid(&path) {
Ok(branch_id) => {
if matches!(self.registry.get(self.config.workspace_id, branch_id).await, Err(error) if error.is_not_found())
{
paths.push(path);
}
}
Err(_) => paths.push(path),
}
}
Ok(paths)
}
pub async fn purge_discarded(&self, older_than_days: u32) -> BranchResult<u64> {
let cutoff = Utc::now() - chrono::Duration::days(older_than_days as i64);
let branches = self.registry.list(self.config.workspace_id, None).await?;
let mut deleted = 0_u64;
for branch in branches {
if let BranchStatus::Discarded { discarded_at } = branch.status {
if discarded_at < cutoff {
let _ = tokio::fs::remove_file(&branch.db_path).await;
let _ = tokio::fs::remove_file(branch.db_path.with_extension("hash")).await;
self.registry
.update_status(branch.id, BranchStatus::Purged)
.await?;
if let Some(path) = branch.db_path.parent() {
let _ = tokio::fs::remove_dir_all(path).await;
deleted += 1;
}
}
}
}
Ok(deleted)
}
}
fn parse_branch_dir_uuid(path: &Path) -> Result<Uuid, BranchError> {
let value = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| BranchError::OrphanedSnapshot(path.to_path_buf()))?;
Uuid::parse_str(value).map_err(|_| BranchError::OrphanedSnapshot(path.to_path_buf()))
}
async fn file_size(path: &Path) -> BranchResult<u64> {
Ok(tokio::fs::metadata(path).await?.len())
}