use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use crate::storage::cas::blob::hash_to_hex;
use crate::storage::cas::{CasStore, RetentionReport};
use super::lease::{MANIFEST_FILE, read_current_generation};
use super::manifest::Manifest;
pub const DEFAULT_JOB_BYTES_MAX: u64 = 128 * 1024 * 1024;
pub const DEFAULT_MAX_GENERATIONS: usize = 2;
#[derive(Debug, Clone)]
pub struct RetentionConfig {
pub max_generations: usize,
pub job_bytes_max: u64,
}
impl Default for RetentionConfig {
fn default() -> Self {
RetentionConfig {
max_generations: DEFAULT_MAX_GENERATIONS,
job_bytes_max: DEFAULT_JOB_BYTES_MAX,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GenerationRetentionReport {
pub cas: RetentionReport,
pub generations_removed: usize,
pub generations_retained: usize,
pub jobs_completed_deleted: usize,
pub jobs_byte_capped: usize,
pub job_bytes_reclaimed: u64,
pub job_bytes_remaining: u64,
pub cas_bytes: u64,
pub cas_blob_count: usize,
pub generation_count: usize,
pub dedup_ratio: f64,
pub gc_candidates: Vec<String>,
}
impl GenerationRetentionReport {
fn write_generation_summary(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Retention Report:")?;
writeln!(f, " Generations:")?;
writeln!(f, " retained: {}", self.generations_retained)?;
writeln!(f, " removed: {}", self.generations_removed)
}
fn write_cas_summary(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, " CAS:")?;
writeln!(f, " blob count: {}", self.cas_blob_count)?;
writeln!(f, " total bytes: {}", self.cas_bytes)?;
writeln!(
f,
" reclaimed: {} bytes ({} blobs)",
self.cas.reclaimed_bytes, self.cas.blobs_removed
)?;
writeln!(f, " dedup ratio: {:.2}", self.dedup_ratio)?;
writeln!(f, " GC candidates: {}", self.gc_candidates.len())
}
fn write_job_summary(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, " Jobs:")?;
writeln!(f, " remaining: {} bytes", self.job_bytes_remaining)?;
writeln!(f, " completed del: {}", self.jobs_completed_deleted)?;
writeln!(f, " byte-capped: {}", self.jobs_byte_capped)?;
writeln!(f, " reclaimed: {} bytes", self.job_bytes_reclaimed)
}
}
impl std::fmt::Display for GenerationRetentionReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.write_generation_summary(f)?;
self.write_cas_summary(f)?;
self.write_job_summary(f)
}
}
#[derive(Debug, thiserror::Error)]
pub enum RetentionError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("cas error: {0}")]
Cas(#[from] crate::storage::cas::CasError),
#[error("manifest error: {0}")]
Manifest(#[from] super::manifest::ManifestError),
}
fn read_generation_numbers(gens_dir: &Path) -> Result<Vec<u64>, RetentionError> {
let mut gen_numbers: Vec<u64> = Vec::new();
if gens_dir.exists() {
for entry in fs::read_dir(gens_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if let Ok(num) = entry.file_name().to_string_lossy().parse::<u64>() {
gen_numbers.push(num);
}
}
}
gen_numbers.sort_unstable();
Ok(gen_numbers)
}
fn generation_manifest(gen_dir: &Path) -> Option<Manifest> {
let bytes = fs::read(gen_dir.join(MANIFEST_FILE)).ok()?;
Manifest::from_bytes(&bytes).ok()
}
fn window_anchor(gen_numbers: &[u64], current_gen: Option<u64>) -> u64 {
current_gen
.filter(|g| gen_numbers.contains(g))
.unwrap_or_else(|| gen_numbers[gen_numbers.len() - 1])
}
fn retention_window(gen_numbers: &[u64], anchor: u64, keep_count: usize) -> HashSet<u64> {
if let Some(idx) = gen_numbers.iter().position(|g| *g == anchor) {
let start = idx.saturating_sub(keep_count - 1);
gen_numbers[start..=idx].iter().copied().collect()
} else {
let start = gen_numbers.len().saturating_sub(keep_count);
gen_numbers[start..].iter().copied().collect()
}
}
fn remove_generation_dir(gen_dir: &Path, label: &str, gen_num: u64) -> bool {
match fs::remove_dir_all(gen_dir) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
warn!("retention: failed to remove {} {}: {}", label, gen_num, e);
false
}
}
}
pub fn retain_after_publish(
store: &mut CasStore,
gens_dir: &Path,
jobs_dir: &Path,
cfg: &RetentionConfig,
) -> Result<GenerationRetentionReport, RetentionError> {
let mut report = GenerationRetentionReport::default();
store.reload()?;
let current_gen = read_current_generation_from_gens_dir(gens_dir);
let (retained, removed, pinned_hashes) =
prune_generations(store, gens_dir, current_gen, cfg.max_generations)?;
report.generations_retained = retained;
report.generations_removed = removed;
let pinned_set: HashSet<[u8; 32]> = pinned_hashes.into_iter().collect();
let gc_candidates = find_gc_candidates(store, &pinned_set);
for hash in &gc_candidates {
report.gc_candidates.push(hash_to_hex(hash));
}
report.cas = match store.gc_with_pins(&pinned_set) {
Ok(gc) => gc,
Err(error) => match error.partial_sweep() {
Some((reclaimed_bytes, blobs_removed)) => {
warn!(
reclaimed_bytes,
blobs_removed,
"CAS GC failed part-way; crediting the partial reclaim and continuing"
);
RetentionReport {
reclaimed_bytes,
blobs_removed,
partial: true,
}
}
None => return Err(error.into()),
},
};
let (completed_deleted, byte_capped, job_bytes_reclaimed) =
prune_jobs(jobs_dir, current_gen, cfg.job_bytes_max)?;
report.jobs_completed_deleted = completed_deleted;
report.jobs_byte_capped = byte_capped;
report.job_bytes_reclaimed = job_bytes_reclaimed;
report.cas_bytes = compute_cas_bytes(store)?;
report.cas_blob_count = store.blob_count()?;
report.generation_count = count_generation_dirs(gens_dir);
report.job_bytes_remaining = dir_total_bytes(jobs_dir);
report.dedup_ratio = compute_dedup_ratio(gens_dir, store)?;
info!(
"retention sweep complete: {} gens retained, {} gens removed, \
{} CAS blobs ({} bytes), {} jobs deleted, {} bytes reclaimed from jobs",
report.generations_retained,
report.generations_removed,
report.cas_blob_count,
report.cas_bytes,
report.jobs_completed_deleted + report.jobs_byte_capped,
report.job_bytes_reclaimed,
);
Ok(report)
}
pub fn retention_report(
store: &CasStore,
gens_dir: &Path,
jobs_dir: &Path,
) -> Result<GenerationRetentionReport, RetentionError> {
let mut report = GenerationRetentionReport::default();
let gen_numbers = read_generation_numbers(gens_dir)?;
report.generation_count = gen_numbers.len();
report.generations_retained = gen_numbers.len();
let (total_layer_refs, unique_layer_hashes) = count_manifest_layers(gens_dir, &gen_numbers);
report.cas_blob_count = store.blob_count()?;
report.cas_bytes = compute_cas_bytes(store)?;
report.gc_candidates = unmanaged_blob_hashes(store, &unique_layer_hashes)?;
if total_layer_refs > 0 {
report.dedup_ratio = 1.0 - (unique_layer_hashes.len() as f64 / total_layer_refs as f64);
}
report.job_bytes_remaining = dir_total_bytes(jobs_dir);
Ok(report)
}
fn count_manifest_layers(gens_dir: &Path, gen_numbers: &[u64]) -> (usize, HashSet<[u8; 32]>) {
let mut total_layer_refs = 0usize;
let mut unique_layer_hashes: HashSet<[u8; 32]> = HashSet::new();
for gen_num in gen_numbers {
if let Some(manifest) = generation_manifest(&gens_dir.join(gen_num.to_string())) {
let hashes = manifest.layer_hashes();
total_layer_refs += hashes.len();
for hash in &hashes {
unique_layer_hashes.insert(*hash);
}
}
}
(total_layer_refs, unique_layer_hashes)
}
fn unmanaged_blob_hashes(
store: &CasStore,
manifest_hashes: &HashSet<[u8; 32]>,
) -> Result<Vec<String>, RetentionError> {
let stored = store.stored_hashes()?;
let mut candidates = Vec::new();
for hash in &stored {
if store.refcount(hash) == 0 && !manifest_hashes.contains(hash) {
candidates.push(hash_to_hex(hash));
}
}
Ok(candidates)
}
fn prune_generations(
store: &CasStore,
gens_dir: &Path,
current_gen: Option<u64>,
max_generations: usize,
) -> Result<(usize, usize, Vec<[u8; 32]>), RetentionError> {
let gen_numbers = read_generation_numbers(gens_dir)?;
if gen_numbers.is_empty() {
return Ok((0, 0, Vec::new()));
}
let current_gen = window_anchor(&gen_numbers, current_gen);
let mut retained_gens = retention_window(&gen_numbers, current_gen, max_generations.max(1));
retain_leased_generations(store, &gen_numbers, &mut retained_gens);
let pinned_hashes = collect_pinned_hashes(gens_dir, &retained_gens);
let leased_now = store.held_generations();
let mut removed = 0;
let retained_count = retained_gens.len();
for gen_num in &gen_numbers {
if !retained_gens.contains(gen_num) && !leased_now.contains(gen_num) {
let gen_dir = gens_dir.join(gen_num.to_string());
debug!(
"retention: removing generation {} (not current/prev/leased)",
gen_num
);
if remove_generation_dir(&gen_dir, "generation", *gen_num) {
removed += 1;
}
}
}
Ok((retained_count, removed, pinned_hashes))
}
fn retain_leased_generations(store: &CasStore, gen_numbers: &[u64], retained: &mut HashSet<u64>) {
let leased = store.held_generations();
for gen_num in gen_numbers {
if !retained.contains(gen_num) && leased.contains(gen_num) {
retained.insert(*gen_num);
}
}
}
fn collect_pinned_hashes(gens_dir: &Path, retained: &HashSet<u64>) -> Vec<[u8; 32]> {
let mut pinned_hashes: Vec<[u8; 32]> = Vec::new();
for gen_num in retained {
if let Some(manifest) = generation_manifest(&gens_dir.join(gen_num.to_string())) {
for hash in manifest.layer_hashes() {
if !pinned_hashes.contains(&hash) {
pinned_hashes.push(hash);
}
}
}
}
pinned_hashes
}
pub fn retain_generations_no_cas(
gens_dir: &Path,
jobs_dir: &Path,
max_generations: usize,
dry_run: bool,
) -> Result<GenerationRetentionReport, RetentionError> {
let mut report = GenerationRetentionReport::default();
let current_gen = read_current_generation_from_gens_dir(gens_dir);
let gen_numbers = read_generation_numbers(gens_dir)?;
report.generation_count = gen_numbers.len();
if gen_numbers.is_empty() {
report.job_bytes_remaining = dir_total_bytes(jobs_dir);
return Ok(report);
}
let current = window_anchor(&gen_numbers, current_gen);
let keep_count = max_generations.max(1);
let retained = retention_window(&gen_numbers, current, keep_count);
for gen_num in &gen_numbers {
if retained.contains(gen_num) {
continue;
}
if dry_run {
report.generations_removed += 1;
continue;
}
let gen_dir = gens_dir.join(gen_num.to_string());
debug!(
"retention: removing legacy generation {} (outside the {}-generation window)",
gen_num, keep_count
);
if remove_generation_dir(&gen_dir, "legacy generation", *gen_num) {
report.generations_removed += 1;
}
}
report.generations_retained = retained.len();
if !dry_run {
let (completed_deleted, byte_capped, job_bytes_reclaimed) =
prune_jobs(jobs_dir, Some(current), DEFAULT_JOB_BYTES_MAX)?;
report.jobs_completed_deleted = completed_deleted;
report.jobs_byte_capped = byte_capped;
report.job_bytes_reclaimed = job_bytes_reclaimed;
}
report.job_bytes_remaining = dir_total_bytes(jobs_dir);
report.generation_count = count_generation_dirs(gens_dir);
Ok(report)
}
fn prune_jobs(
jobs_dir: &Path,
current_gen: Option<u64>,
job_bytes_max: u64,
) -> Result<(usize, usize, u64), RetentionError> {
if !jobs_dir.exists() {
return Ok((0, 0, 0));
}
let (completed_deleted, mut bytes_reclaimed) = match current_gen {
Some(cur_gen) => delete_completed_jobs_for_current_gen(jobs_dir, cur_gen)?,
None => (0, 0),
};
let total_job_bytes = dir_total_bytes(jobs_dir);
if total_job_bytes <= job_bytes_max {
return Ok((completed_deleted, 0, bytes_reclaimed));
}
let completed_jobs = completed_jobs_oldest_first(jobs_dir)?;
let (byte_capped, reclaimed) =
enforce_job_byte_cap(&completed_jobs, total_job_bytes, job_bytes_max);
bytes_reclaimed += reclaimed;
Ok((completed_deleted, byte_capped, bytes_reclaimed))
}
fn delete_completed_jobs_for_current_gen(
jobs_dir: &Path,
current_gen: u64,
) -> Result<(usize, u64), RetentionError> {
let job_entries: Vec<PathBuf> = fs::read_dir(jobs_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect();
let mut completed_deleted = 0;
let mut bytes_reclaimed: u64 = 0;
for job_dir in &job_entries {
if job_produced_generation(job_dir, current_gen) && job_is_completed(job_dir) {
let size = dir_total_bytes(job_dir);
if let Err(e) = fs::remove_dir_all(job_dir) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(
"retention: failed to remove completed job {}: {}",
job_dir.display(),
e
);
}
} else {
debug!(
"retention: deleted completed job {} (gen {} published)",
job_dir.display(),
current_gen
);
completed_deleted += 1;
bytes_reclaimed += size;
}
}
}
Ok((completed_deleted, bytes_reclaimed))
}
fn completed_jobs_oldest_first(
jobs_dir: &Path,
) -> Result<Vec<(std::time::SystemTime, PathBuf, u64)>, RetentionError> {
let mut completed_jobs: Vec<(std::time::SystemTime, PathBuf, u64)> = Vec::new();
for entry in fs::read_dir(jobs_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let path = entry.path();
if !job_is_completed(&path) {
debug!(
"retention: skipping in-progress job {} for byte cap",
path.display()
);
continue;
}
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let size = dir_total_bytes(&path);
completed_jobs.push((mtime, path, size));
}
completed_jobs.sort_by_key(|(mtime, _, _)| *mtime);
Ok(completed_jobs)
}
fn enforce_job_byte_cap(
completed_jobs: &[(std::time::SystemTime, PathBuf, u64)],
total_job_bytes: u64,
job_bytes_max: u64,
) -> (usize, u64) {
let mut byte_capped = 0;
let mut bytes_reclaimed: u64 = 0;
let mut current_bytes = total_job_bytes;
for (_mtime, path, size) in completed_jobs {
if current_bytes <= job_bytes_max {
break;
}
debug!(
"retention: byte-capping job {} ({} bytes, oldest completed)",
path.display(),
size
);
if let Err(e) = fs::remove_dir_all(path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(
"retention: failed to byte-cap job {}: {}",
path.display(),
e
);
}
} else {
byte_capped += 1;
bytes_reclaimed += size;
current_bytes = current_bytes.saturating_sub(*size);
}
}
(byte_capped, bytes_reclaimed)
}
fn read_current_generation_from_gens_dir(gens_dir: &Path) -> Option<u64> {
let storage_root = gens_dir.parent()?;
read_current_generation(storage_root)
}
fn job_produced_generation(job_dir: &Path, generation: u64) -> bool {
let gen_file = job_dir.join("generation");
if let Ok(content) = fs::read_to_string(&gen_file) {
return content.trim() == generation.to_string();
}
let name = job_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
name == generation.to_string()
|| name.starts_with(&format!("{}-", generation))
|| name.starts_with(&format!("{}_", generation))
}
fn job_is_completed(job_dir: &Path) -> bool {
if job_dir.join("completed").exists() {
return true;
}
if let Ok(content) = fs::read_to_string(job_dir.join("status")) {
if content.trim().eq_ignore_ascii_case("completed") {
return true;
}
}
if let Ok(content) = fs::read_to_string(job_dir.join("checkpoint.json")) {
if content.contains("\"completed\"") || content.contains("\"status\":\"completed\"") {
return true;
}
}
if job_dir.join("neural.complete").exists()
&& job_dir.join("pdg.complete").exists()
&& job_dir.join("lexical.complete").exists()
{
return true;
}
false
}
fn dir_total_bytes(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![path.to_path_buf()];
while let Some(current) = stack.pop() {
let entries = match fs::read_dir(¤t) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
stack.push(entry.path());
} else {
total += meta.len();
}
}
}
total
}
fn count_generation_dirs(gens_dir: &Path) -> usize {
if !gens_dir.exists() {
return 0;
}
let mut count = 0;
if let Ok(entries) = fs::read_dir(gens_dir) {
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_dir()) {
let name = entry.file_name();
if name.to_string_lossy().parse::<u64>().is_ok() {
count += 1;
}
}
}
}
count
}
fn compute_cas_bytes(store: &CasStore) -> Result<u64, RetentionError> {
let root = store.root();
let mut total: u64 = 0;
if let Ok(entries) = fs::read_dir(root) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "refs.json" || name == ".staging" {
continue;
}
if entry.file_type().is_ok_and(|t| t.is_dir()) {
total += dir_total_bytes(&path);
} else if entry.file_type().is_ok_and(|t| t.is_file()) && name != "refs.json" {
if let Ok(meta) = entry.metadata() {
total += meta.len();
}
}
}
}
Ok(total)
}
fn compute_dedup_ratio(gens_dir: &Path, _store: &CasStore) -> Result<f64, RetentionError> {
let mut total_refs = 0usize;
let mut unique: HashSet<[u8; 32]> = HashSet::new();
if !gens_dir.exists() {
return Ok(0.0);
}
for entry in fs::read_dir(gens_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let manifest_path = entry.path().join(MANIFEST_FILE);
if let Ok(bytes) = fs::read(&manifest_path) {
if let Ok(manifest) = Manifest::from_bytes(&bytes) {
for hash in manifest.layer_hashes() {
total_refs += 1;
unique.insert(hash);
}
}
}
}
if total_refs == 0 {
return Ok(0.0);
}
Ok(1.0 - (unique.len() as f64 / total_refs as f64))
}
fn find_gc_candidates(store: &CasStore, pinned: &HashSet<[u8; 32]>) -> Vec<[u8; 32]> {
let mut candidates = Vec::new();
if let Ok(hashes) = store.stored_hashes() {
for hash in hashes {
let rc = store.refcount(&hash);
if rc == 0 && !pinned.contains(&hash) {
candidates.push(hash);
}
}
}
candidates
}
#[cfg(test)]
#[path = "retention_test.rs"]
mod tests;