use crate::journal::{Journal, JournalError};
use crate::snapshot::{SnapshotError, Store};
use std::path::Path;
const DAY_MS: i64 = 24 * 60 * 60 * 1000;
const TMP_MAX_AGE_MS: u64 = 60 * 60 * 1000;
const HOT_WINDOW_MS: i64 = TMP_MAX_AGE_MS as i64;
const EVICTION_BATCH: u32 = 64;
#[derive(Debug, thiserror::Error)]
pub enum MaintenanceError {
#[error(transparent)]
Journal(#[from] JournalError),
#[error(transparent)]
Snapshot(#[from] SnapshotError),
}
pub struct GcOptions {
pub keep_days: i64,
pub dry_run: bool,
pub cap_bytes: Option<u64>,
pub min_free_bytes: Option<u64>,
pub time_budget: Option<std::time::Duration>,
}
#[derive(Debug, Clone, Copy)]
pub struct MaintenanceBudget {
pub cap_bytes: Option<u64>,
pub min_free_bytes: Option<u64>,
pub gc_every: u64,
pub keep_days: i64,
}
impl MaintenanceBudget {
pub fn from_env() -> Self {
let get = |k: &str| std::env::var(k).ok();
Self {
cap_bytes: parse_opt_bytes(get("DOOVER_MAX_STORE_BYTES").as_deref(), 5 << 30),
min_free_bytes: parse_opt_bytes(get("DOOVER_MIN_FREE_BYTES").as_deref(), 1 << 30),
gc_every: parse_u64_or(get("DOOVER_GC_EVERY").as_deref(), 50).min(i64::MAX as u64),
keep_days: match parse_u64_or(get("DOOVER_KEEP_DAYS").as_deref(), 7) {
0 => i64::MAX,
d => d.min(i64::MAX as u64) as i64,
},
}
}
pub fn disabled() -> Self {
Self {
cap_bytes: None,
min_free_bytes: None,
gc_every: 0,
keep_days: 7,
}
}
}
pub fn auto_gc_options(b: &MaintenanceBudget) -> GcOptions {
GcOptions {
keep_days: b.keep_days,
dry_run: false,
cap_bytes: b.cap_bytes,
min_free_bytes: None,
time_budget: Some(std::time::Duration::from_secs(3)),
}
}
fn parse_opt_bytes(v: Option<&str>, default: u64) -> Option<u64> {
match v {
None => Some(default),
Some(s) => match s.trim().parse::<u64>() {
Ok(0) => None,
Ok(n) => Some(n),
Err(_) => Some(default),
},
}
}
fn parse_u64_or(v: Option<&str>, default: u64) -> u64 {
v.and_then(|s| s.trim().parse().ok()).unwrap_or(default)
}
#[derive(Debug, Default)]
pub struct GcReport {
pub dry_run: bool,
pub cutoff_ms: Option<i64>,
pub objects_removed: u64,
pub bytes_freed: u64,
pub actions_pruned: u64,
pub sessions_pruned: u64,
pub tmp_removed: u64,
pub over_cap_bytes_before: u64,
pub free_deficit_bytes_before: u64,
pub cap_evicted_actions: u64,
pub still_over_budget: bool,
}
pub fn gc(
journal: &Journal,
store: &Store,
doover_home: &Path,
opts: &GcOptions,
) -> Result<GcReport, MaintenanceError> {
let mut report = GcReport {
dry_run: opts.dry_run,
..Default::default()
};
report.tmp_removed = store.clean_tmp(TMP_MAX_AGE_MS, opts.dry_run)?;
let Some(recorded_newest) = journal.max_started_at()? else {
return Ok(report); };
let newest = recorded_newest.min(crate::journal::now_ms());
let cutoff = newest.saturating_sub(opts.keep_days.max(0).saturating_mul(DAY_MS));
report.cutoff_ms = Some(cutoff);
let live = journal.live_hashes(cutoff)?;
let (objects_removed, bytes_freed) = store.prune(&live, TMP_MAX_AGE_MS, opts.dry_run)?;
report.objects_removed = objects_removed;
report.bytes_freed = bytes_freed;
let (actions_pruned, sessions_pruned) = journal.prune_before(cutoff, opts.dry_run)?;
report.actions_pruned = actions_pruned;
report.sessions_pruned = sessions_pruned;
let apparent = store.total_bytes()?;
let cap_credit = if opts.dry_run { report.bytes_freed } else { 0 };
let over_cap = match opts.cap_bytes {
Some(cap) => apparent.saturating_sub(cap_credit).saturating_sub(cap),
None => 0,
};
let deficit = free_deficit(doover_home, opts);
report.over_cap_bytes_before = over_cap;
report.free_deficit_bytes_before = deficit;
if opts.dry_run || (over_cap == 0 && deficit == 0) {
return Ok(report);
}
let deadline = opts.time_budget.map(|d| std::time::Instant::now() + d);
let ceiling = newest.saturating_sub(HOT_WINDOW_MS);
let mut apparent_now = apparent;
loop {
if deadline.is_some_and(|dl| std::time::Instant::now() >= dl) {
report.still_over_budget = true;
break;
}
let Some(batch_end) = journal.oldest_evictable_batch_end(EVICTION_BATCH, ceiling)? else {
report.still_over_budget = true;
break;
};
let evict_cutoff = batch_end.saturating_add(1).min(ceiling);
let live = journal.live_hashes(evict_cutoff)?;
let (objs, bytes) = store.prune(&live, TMP_MAX_AGE_MS, false)?;
let (actions, sessions) = journal.prune_before(evict_cutoff, false)?;
report.objects_removed += objs;
report.bytes_freed += bytes;
report.cap_evicted_actions += actions;
report.sessions_pruned += sessions;
apparent_now = apparent_now.saturating_sub(bytes);
let over_cap = match opts.cap_bytes {
Some(cap) => apparent_now.saturating_sub(cap),
None => 0,
};
if over_cap == 0 && free_deficit(doover_home, opts) == 0 {
break;
}
if actions == 0 && objs == 0 {
report.still_over_budget = true;
break;
}
}
Ok(report)
}
fn free_deficit(doover_home: &Path, opts: &GcOptions) -> u64 {
match opts.min_free_bytes {
Some(floor) => match crate::snapshot::free_bytes(doover_home) {
Some(free) => floor.saturating_sub(free),
None => 0,
},
None => 0,
}
}
#[cfg(test)]
mod budget_parse_tests {
use super::{parse_opt_bytes, parse_u64_or};
#[test]
fn byte_budget_parse_is_fail_safe() {
assert_eq!(parse_opt_bytes(None, 5 << 30), Some(5 << 30));
assert_eq!(parse_opt_bytes(Some("garbage"), 5 << 30), Some(5 << 30));
assert_eq!(parse_opt_bytes(Some("-1"), 5 << 30), Some(5 << 30));
assert_eq!(parse_opt_bytes(Some(""), 5 << 30), Some(5 << 30));
assert_eq!(parse_opt_bytes(Some("0"), 5 << 30), None);
assert_eq!(parse_opt_bytes(Some(" 1048576 "), 5 << 30), Some(1_048_576));
assert_eq!(parse_u64_or(Some("25"), 50), 25);
assert_eq!(parse_u64_or(Some("junk"), 50), 50);
assert_eq!(parse_u64_or(None, 50), 50);
}
}
#[cfg(test)]
mod auto_gc_options_tests {
use super::{MaintenanceBudget, auto_gc_options};
#[test]
fn auto_gc_options_pins_the_trigger_contract() {
let b = MaintenanceBudget {
cap_bytes: Some(123),
min_free_bytes: Some(999), gc_every: 50,
keep_days: 7,
};
let o = auto_gc_options(&b);
assert!(!o.dry_run);
assert_eq!(o.cap_bytes, Some(123), "cap passes through");
assert_eq!(o.keep_days, 7, "retention passes through");
assert_eq!(
o.min_free_bytes, None,
"floor NEVER drives automatic eviction (manual gc only)"
);
assert_eq!(
o.time_budget,
Some(std::time::Duration::from_secs(3)),
"the triggered pass always carries the 3s budget (D1 discipline)"
);
}
}