fix(local-catalog): prune obsolete generations
cx
This commit is contained in:
6 files changed
+188
-3
No files matched your search
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
log.workspace = true
|
||||
rusqlite = { version = "0.40", features = ["backup", "bundled"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -106,6 +106,13 @@ pub struct BootstrapManifest {
|
||||
pub activated_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct GenerationPruneReport {
|
||||
pub removed_generations: usize,
|
||||
pub reclaimed_bytes: u64,
|
||||
pub failed_generations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FallbackReason {
|
||||
NoManifest,
|
||||
@@ -331,9 +338,136 @@ impl BootstrapLayout {
|
||||
}
|
||||
fs::rename(&temporary, &path)?;
|
||||
sync_directory(&self.data_dir)?;
|
||||
self.prune_obsolete_generations(manifest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retain only the active catalog and its immediate rollback generation.
|
||||
///
|
||||
/// The manifest is the source of truth and is always published before this
|
||||
/// maintenance runs. Cleanup is deliberately best-effort: Windows may
|
||||
/// still have an older generation open briefly, and a failed unlink must
|
||||
/// never make a successfully activated catalog look like a failed one.
|
||||
/// A later startup or activation will retry any generation left behind.
|
||||
pub fn prune_obsolete_generations(
|
||||
&self,
|
||||
manifest: &BootstrapManifest,
|
||||
) -> GenerationPruneReport {
|
||||
let entries = match fs::read_dir(&self.data_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"[local-catalog] generation cleanup could not scan {}: {error}",
|
||||
self.data_dir.display()
|
||||
);
|
||||
return GenerationPruneReport {
|
||||
failed_generations: 1,
|
||||
..GenerationPruneReport::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut generations = BTreeSet::new();
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let generation = [".db", ".db-wal", ".db-shm"]
|
||||
.into_iter()
|
||||
.find_map(|suffix| parse_generation_name(&name, suffix));
|
||||
if let Some(generation) = generation {
|
||||
generations.insert(generation);
|
||||
}
|
||||
}
|
||||
// A schema rebuild may not be able to validate the previous manifest,
|
||||
// leaving `previous_generation` empty even though the immediately
|
||||
// preceding finalized database is still the safest rollback artifact.
|
||||
let previous_generation = manifest.previous_generation.or_else(|| {
|
||||
generations
|
||||
.range(..manifest.active_generation)
|
||||
.next_back()
|
||||
.copied()
|
||||
});
|
||||
let keep = [Some(manifest.active_generation), previous_generation]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let stale = generations
|
||||
.into_iter()
|
||||
.filter(|generation| !keep.contains(generation))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut report = GenerationPruneReport::default();
|
||||
for generation in stale {
|
||||
let database = self.generation_path(generation);
|
||||
let mut failed = false;
|
||||
let mut removed_any = false;
|
||||
let database_bytes = fs::metadata(&database)
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
let database_gone = match fs::remove_file(&database) {
|
||||
Ok(()) => {
|
||||
removed_any = true;
|
||||
report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(database_bytes);
|
||||
true
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
|
||||
Err(error) => {
|
||||
failed = true;
|
||||
log::warn!(
|
||||
"[local-catalog] generation cleanup could not remove {}: {error}",
|
||||
database.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
// If Windows still has the database open, leave its WAL/SHM pair
|
||||
// untouched as well. The next maintenance pass retries the trio.
|
||||
for path in database_gone
|
||||
.then(|| {
|
||||
[
|
||||
PathBuf::from(format!("{}-wal", database.display())),
|
||||
PathBuf::from(format!("{}-shm", database.display())),
|
||||
]
|
||||
})
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let bytes = fs::metadata(&path)
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
removed_any = true;
|
||||
report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(bytes);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
failed = true;
|
||||
log::warn!(
|
||||
"[local-catalog] generation cleanup could not remove {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
report.failed_generations += 1;
|
||||
} else if removed_any {
|
||||
report.removed_generations += 1;
|
||||
}
|
||||
}
|
||||
if report.removed_generations > 0 || report.failed_generations > 0 {
|
||||
log::info!(
|
||||
"[local-catalog] generation cleanup: removed={} reclaimed_bytes={} failed={} retained_active={} retained_previous={:?}",
|
||||
report.removed_generations,
|
||||
report.reclaimed_bytes,
|
||||
report.failed_generations,
|
||||
manifest.active_generation,
|
||||
previous_generation
|
||||
);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
pub(crate) fn next_building_generation(&self, active_generation: Option<u64>) -> Result<u64> {
|
||||
let mut maximum_final = active_generation.unwrap_or(0);
|
||||
let mut building = Vec::new();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -182,6 +183,51 @@ fn building_file_is_never_active_and_previous_generation_survives() {
|
||||
assert_eq!(layout.read_manifest().unwrap().active_generation, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_prunes_generations_older_than_the_rollback_generation() {
|
||||
let temp = tempdir().unwrap();
|
||||
let layout = BootstrapLayout::new(temp.path());
|
||||
let key = source(SourceKind::Local, "library");
|
||||
|
||||
for generation in 1..=3 {
|
||||
let snapshot = format!("v{generation}");
|
||||
let current_probe = probe(&temp.path().join("library.db"), &key, &snapshot, generation);
|
||||
let (mut session, _) = layout
|
||||
.prepare(&[current_probe.clone()], Some(u64::MAX))
|
||||
.unwrap();
|
||||
apply_range(&mut session, &key, &snapshot, 0, generation, generation);
|
||||
session.activate(&[current_probe]).unwrap();
|
||||
|
||||
if generation == 2 {
|
||||
fs::write(
|
||||
PathBuf::from(format!("{}-wal", layout.generation_path(1).display())),
|
||||
b"stale wal",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
PathBuf::from(format!("{}-shm", layout.generation_path(1).display())),
|
||||
b"stale shm",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("local_catalog-v1-g1.db.backup"),
|
||||
b"keep me",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = layout.read_manifest().unwrap();
|
||||
assert_eq!(manifest.active_generation, 3);
|
||||
assert_eq!(manifest.previous_generation, Some(2));
|
||||
assert!(!layout.generation_path(1).exists());
|
||||
assert!(!PathBuf::from(format!("{}-wal", layout.generation_path(1).display())).exists());
|
||||
assert!(!PathBuf::from(format!("{}-shm", layout.generation_path(1).display())).exists());
|
||||
assert!(layout.generation_path(2).is_file());
|
||||
assert!(layout.generation_path(3).is_file());
|
||||
assert!(temp.path().join("local_catalog-v1-g1.db.backup").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_bootstrap_resumes_at_ten_fifty_and_ninety_nine_percent() {
|
||||
const TOTAL: u64 = 1_001;
|
||||
|
||||
@@ -92,6 +92,9 @@ pub fn bootstrap_legacy_caches_at_with_progress(
|
||||
) -> Result<BootstrapOutcome> {
|
||||
let layout = BootstrapLayout::new(&locations.catalog_dir);
|
||||
if let ActiveCatalog::Ready { catalog, manifest } = layout.open_active() {
|
||||
// Also performs the one-time migration for profiles created by builds
|
||||
// that published generations but never removed obsolete ones.
|
||||
layout.prune_obsolete_generations(&manifest);
|
||||
return Ok(BootstrapOutcome::Activated {
|
||||
generation: manifest.active_generation,
|
||||
track_count: catalog.stats()?.track_count,
|
||||
|
||||
@@ -13,8 +13,8 @@ mod schema;
|
||||
|
||||
pub use bootstrap::{
|
||||
ActiveCatalog, BootstrapBatch, BootstrapLayout, BootstrapManifest, BootstrapOutcome,
|
||||
BootstrapProgress, BootstrapSession, FallbackReason, PreflightReport, SourceCheckpoint,
|
||||
SourceProbe, BOOTSTRAP_BATCH_ROWS,
|
||||
BootstrapProgress, BootstrapSession, FallbackReason, GenerationPruneReport, PreflightReport,
|
||||
SourceCheckpoint, SourceProbe, BOOTSTRAP_BATCH_ROWS,
|
||||
};
|
||||
pub use catalog::{
|
||||
normalize_artist_key, normalize_sort_key, Catalog, CatalogStats, IntegrityReport, QueryMetrics,
|
||||
|
||||
Reference in new issue
Block a user