use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use crate::v6::{
fault::reach_generation_boundary, ArtifactCleanupLimits, ArtifactCleanupReport,
ArtifactReachability, FormatError, FormatResult, GenerationCrashPoint, ImmutableMemberLocator,
};
use super::discovery::{discover_artifacts, DiscoveredLocation, DiscoveredMember};
use super::filesystem::{
begin_cycle, delete_member, quarantine_member, remove_empty_generation, restore_member,
};
static ACTIVE_ROOTS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct ArtifactGarbageCollector {
root: PathBuf,
limits: ArtifactCleanupLimits,
}
impl ArtifactGarbageCollector {
pub fn new(root: impl Into<PathBuf>, limits: ArtifactCleanupLimits) -> Self {
Self {
root: root.into(),
limits,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub const fn limits(&self) -> ArtifactCleanupLimits {
self.limits
}
pub(crate) fn run_cycle(
&self,
reachable: &ArtifactReachability,
now_unix_ns: u64,
) -> FormatResult<ArtifactCleanupReport> {
let _admission = RootAdmission::acquire(&self.root)?;
let started = Instant::now();
reach_generation_boundary(GenerationCrashPoint::GcEnumerationError).map_err(|error| {
FormatError::CleanupIo {
operation: "inject cleanup enumeration failure",
kind: error.kind(),
}
})?;
let discovery = discover_artifacts(&self.root, self.limits, started)?;
let final_by_id = validate_unique_final(&discovery.final_artifacts)?;
validate_unique_quarantine(&discovery.quarantined_artifacts)?;
let mut restores = Vec::new();
let mut deletions = Vec::new();
for artifact in &discovery.quarantined_artifacts {
match artifact.location {
DiscoveredLocation::Quarantine(_) => {}
DiscoveredLocation::Final => {
return invalid("final artifact appeared in quarantine discovery")
}
}
if reachable.contains(artifact.reference) {
let canonical_present = final_by_id
.get(&artifact.reference.locator())
.is_some_and(|candidate| candidate.reference == artifact.reference);
restores.push((artifact, canonical_present));
} else if reachable.conflicting_reference(artifact.reference) {
return invalid("quarantined artifact conflicts with a reachable identity");
} else {
deletions.push(artifact);
}
}
let restore_cost = validate_restore_budget(&restores, self.limits)?;
if !restores.is_empty() {
reach_generation_boundary(GenerationCrashPoint::GcLeaseAppeared).map_err(|error| {
FormatError::CleanupIo {
operation: "inject cleanup root reappearance",
kind: error.kind(),
}
})?;
}
if !deletions.is_empty() {
reach_generation_boundary(GenerationCrashPoint::GcAfterSecondRootProof).map_err(
|error| FormatError::CleanupIo {
operation: "inject after second cleanup proof",
kind: error.kind(),
},
)?;
}
let generation = begin_cycle(&self.root, &discovery.quarantine_generations)?;
let mut report = ArtifactCleanupReport::new(generation, reachable.len() as u64);
for _ in &discovery.final_artifacts {
report.inspect_final();
}
for _ in &discovery.quarantined_artifacts {
report.inspect_quarantine();
}
for (artifact, canonical_present) in restores {
restore_member(&self.root, artifact, canonical_present)?;
report.restore(artifact.reference.byte_length());
}
let mut budget = MutationBudget::new(self.limits, started, restore_cost);
for artifact in deletions {
let DiscoveredLocation::Quarantine(quarantined_at) = artifact.location else {
return invalid("delete candidate is not quarantined");
};
if quarantined_at >= generation {
report.defer();
continue;
}
if !budget.admit_unlink(artifact.reference.byte_length()) {
report.defer();
continue;
}
delete_member(artifact)?;
report.delete(artifact.reference.byte_length());
}
for artifact in &discovery.final_artifacts {
if reachable.contains(artifact.reference) {
continue;
}
if reachable.conflicting_reference(artifact.reference) {
return invalid("final artifact conflicts with a reachable identity");
}
if !is_old_enough(artifact, now_unix_ns, self.limits) {
report.defer();
continue;
}
if !budget.admit_rename(artifact.reference.byte_length()) {
report.defer();
continue;
}
quarantine_member(&self.root, artifact, generation)?;
report.quarantine(artifact.reference.byte_length());
}
for old_generation in discovery.quarantine_generations {
remove_empty_generation(&self.root, old_generation)?;
}
Ok(report)
}
}
fn validate_unique_final(
artifacts: &[DiscoveredMember],
) -> FormatResult<HashMap<ImmutableMemberLocator, &DiscoveredMember>> {
let mut by_id = HashMap::new();
for artifact in artifacts {
if by_id
.insert(artifact.reference.locator(), artifact)
.is_some()
{
return invalid("one final immutable locator has multiple identities");
}
}
Ok(by_id)
}
fn validate_unique_quarantine(artifacts: &[DiscoveredMember]) -> FormatResult<()> {
let mut identities = HashSet::new();
for artifact in artifacts {
if !identities.insert(artifact.reference.locator()) {
return invalid("one immutable locator appears in multiple quarantine locations");
}
}
Ok(())
}
fn validate_restore_budget(
restores: &[(&DiscoveredMember, bool)],
limits: ArtifactCleanupLimits,
) -> FormatResult<RestoreCost> {
let renames = restores.iter().filter(|(_, present)| !*present).count() as u64;
let unlinks = restores.iter().filter(|(_, present)| *present).count() as u64;
let bytes = restores.iter().try_fold(0_u64, |total, (artifact, _)| {
total
.checked_add(artifact.reference.byte_length())
.ok_or(FormatError::InvalidCleanup {
detail: "restore byte accounting overflow",
})
})?;
for (field, actual, limit) in [
("root-restoration renames", renames, limits.max_renames()),
("root-restoration unlinks", unlinks, limits.max_unlinks()),
("root-restoration bytes", bytes, limits.max_mutation_bytes()),
] {
if actual > limit {
return Err(FormatError::CleanupLimitExceeded {
field,
actual,
limit,
});
}
}
Ok(RestoreCost {
renames,
unlinks,
bytes,
})
}
fn is_old_enough(
artifact: &DiscoveredMember,
now_unix_ns: u64,
limits: ArtifactCleanupLimits,
) -> bool {
let minimum_age = u64::try_from(limits.orphan_min_age().as_nanos()).unwrap_or(u64::MAX);
now_unix_ns >= artifact.modified_unix_ns
&& now_unix_ns.saturating_sub(artifact.modified_unix_ns) >= minimum_age
}
struct MutationBudget {
limits: ArtifactCleanupLimits,
started: Instant,
renames: u64,
unlinks: u64,
bytes: u64,
}
#[derive(Clone, Copy)]
struct RestoreCost {
renames: u64,
unlinks: u64,
bytes: u64,
}
impl MutationBudget {
const fn new(
limits: ArtifactCleanupLimits,
started: Instant,
restore_cost: RestoreCost,
) -> Self {
Self {
limits,
started,
renames: restore_cost.renames,
unlinks: restore_cost.unlinks,
bytes: restore_cost.bytes,
}
}
fn admit_rename(&mut self, bytes: u64) -> bool {
if self.renames >= self.limits.max_renames() || !self.admit_bytes(bytes) {
return false;
}
self.renames += 1;
true
}
fn admit_unlink(&mut self, bytes: u64) -> bool {
if self.unlinks >= self.limits.max_unlinks() || !self.admit_bytes(bytes) {
return false;
}
self.unlinks += 1;
true
}
fn admit_bytes(&mut self, bytes: u64) -> bool {
if self.started.elapsed() >= self.limits.max_wall_time() {
return false;
}
let Some(next) = self.bytes.checked_add(bytes) else {
return false;
};
if next > self.limits.max_mutation_bytes() {
return false;
}
self.bytes = next;
true
}
}
struct RootAdmission {
root: PathBuf,
}
impl RootAdmission {
fn acquire(root: &Path) -> FormatResult<Self> {
let root = std::fs::canonicalize(root).map_err(|error| FormatError::CleanupIo {
operation: "canonicalize database root",
kind: error.kind(),
})?;
let mut roots = active_roots()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !roots.insert(root.clone()) {
return Err(FormatError::CleanupBusy);
}
Ok(Self { root })
}
}
impl Drop for RootAdmission {
fn drop(&mut self) {
active_roots()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&self.root);
}
}
fn active_roots() -> &'static Mutex<HashSet<PathBuf>> {
ACTIVE_ROOTS.get_or_init(|| Mutex::new(HashSet::new()))
}
fn invalid<T>(detail: &'static str) -> FormatResult<T> {
Err(FormatError::InvalidCleanup { detail })
}