diff --git a/crates/catalog/examples/persist_bench.rs b/crates/catalog/examples/persist_bench.rs
index addf3a58d..60ec46aa7 100644
--- a/crates/catalog/examples/persist_bench.rs
+++ b/crates/catalog/examples/persist_bench.rs
@@ -1,5 +1,6 @@
//! Catalog persistence costs on large synthetic catalogs (issue #37): log append, compaction
-//! (snapshot serialise / write+fsync / log reset), load, and a catalog clone.
+//! (snapshot serialise / write+fsync / log reset), synchronous and on the background worker, load,
+//! and a catalog clone.
//!
//! ```text
//! cargo run --release -p lightcraft-catalog --example persist_bench --
[photos…]
@@ -119,6 +120,39 @@ fn main() -> std::result::Result<(), Box> {
}
println!(" compaction total: {}", stats(totals));
+ // the same at the threshold, written by the worker while single-op appends go on
+ let mut blocking = Vec::new();
+ for _ in 0..3 {
+ while !j.wants_snapshot() {
+ let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
+ cat.apply(op.clone())?;
+ j.append(&[op])?;
+ }
+ let t = Instant::now();
+ j.snapshot_in_background(&cat)?;
+ let start_ms = ms(t);
+ let mut during = Vec::new();
+ while !j.snapshot_written() {
+ let op = Op::SetRating { id: ids.get(1).copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
+ cat.apply(op.clone())?;
+ j.append(&[op])?;
+ during.push(j.stats().last_append_ms);
+ }
+ let t = Instant::now();
+ j.poll()?;
+ let finish_ms = ms(t);
+ let s = j.stats().last_snapshot;
+ println!(
+ " background compaction ({} records): start {start_ms:.1} ms + finish {finish_ms:.1} ms blocking; worker {:.1} ms; {} appends during it: {}",
+ s.records,
+ s.total_ms,
+ during.len(),
+ stats(during)
+ );
+ blocking.push(s.blocking_ms);
+ }
+ println!(" background compaction blocking: {}", stats(blocking));
+
drop(j);
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
// peak memory of building + compacting only
diff --git a/crates/catalog/proptest-regressions/tests_background.txt b/crates/catalog/proptest-regressions/tests_background.txt
new file mode 100644
index 000000000..e84ff8c01
--- /dev/null
+++ b/crates/catalog/proptest-regressions/tests_background.txt
@@ -0,0 +1,8 @@
+# Seeds for failure cases proptest has generated in the past. It is
+# automatically read and these particular cases re-run before any
+# novel cases are generated.
+#
+# It is recommended to check this file in to source control so that
+# everyone who runs the test benefits from these saved cases.
+cc 0227748e171237a13833c9175fe02c7fe6ff332266798961a6a43ec37b81d1b4 # shrinks to actions = [(3, 0), (0, 0), (4, 0), (0, 0)]
+cc 0b0ab2531bd46fbe261b55ecffebea9d3e8949900ab7bc33fb27ca19f5cd7b8b # shrinks to actions = [(4, 443), (3, 474), (2, 27), (5, 858), (5, 466), (0, 901), (4, 907), (3, 839), (0, 630), (7, 578), (4, 13), (2, 361), (6, 136), (6, 12), (6, 223), (1, 394), (6, 78), (5, 837), (6, 765), (4, 207), (2, 185), (2, 583), (2, 56), (5, 430), (1, 702), (1, 547), (5, 552), (4, 762), (0, 443), (0, 851), (7, 240), (4, 438), (3, 599), (6, 70), (4, 694), (1, 327), (7, 859), (6, 814), (4, 336), (3, 438), (0, 131), (0, 831), (3, 18), (2, 963), (6, 10), (3, 114)]
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index f0a5fcf8b..c146b8b42 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -14,6 +14,19 @@
//! the log) are skipped;
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
//! as `catalog.log.damaged-`, and a fresh snapshot is written so the library stays usable.
+//!
+//! **Background compaction** ([`Journal::snapshot_in_background`], when the store offers a
+//! [`Store::background_writer`]): a worker thread writes the snapshot of a copy of the catalog at
+//! op `N` while appends go on (durably, as always) to the same log. Once the snapshot is durable,
+//! [`Journal::poll`] atomically replaces the log with the records appended since the snapshot
+//! started (`seq > N`, kept in memory). Every crash point is covered by the rules above:
+//! - before the rename of `catalog.snap`: the old snapshot + the whole log (nothing was removed);
+//! - between the rename and the log rewrite: the new snapshot + the whole log, whose records
+//! `<= N` are skipped as stale;
+//! - after the log rewrite: the new snapshot + exactly the records `> N`.
+//!
+//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
+//! first wait for it, so an older snapshot can never replace a newer one.
use crate::store::Store;
use crate::{Catalog, CatalogError, Op, Result};
@@ -72,6 +85,13 @@ pub struct PersistStats {
pub last_snapshot: SnapshotTiming,
/// Total time of the slowest snapshot.
pub max_snapshot_ms: f64,
+ /// Longest time a snapshot blocked the caller (for background snapshots: copying the catalog
+ /// and starting the worker, plus rewriting the log when it is done).
+ pub max_blocking_ms: f64,
+ /// A background snapshot is being written.
+ pub snapshot_running: bool,
+ /// Background snapshots that failed (the log was kept whole; retried later).
+ pub failed_snapshots: u64,
}
/// One snapshot (compaction), by stage.
@@ -82,9 +102,15 @@ pub struct SnapshotTiming {
pub serialize_ms: f64,
/// Flushing, `sync_all` and the atomic rename of `catalog.snap`.
pub write_sync_ms: f64,
- /// Resetting `catalog.log` (an atomic rewrite to empty, fsynced).
+ /// Resetting `catalog.log` (an atomic rewrite to empty, or to the records appended while a
+ /// background snapshot ran; fsynced).
pub reset_ms: f64,
+ /// Start to finish (for a background snapshot: until [`Journal::poll`] saw it done).
pub total_ms: f64,
+ /// How long the caller was blocked (equal to `total_ms` unless `background`).
+ pub blocking_ms: f64,
+ /// Written by a worker thread.
+ pub background: bool,
/// Size of `catalog.snap`.
pub bytes: u64,
/// Log records the snapshot compacted.
@@ -110,6 +136,25 @@ pub struct Journal {
log_bytes: u64,
pub policy: SnapshotPolicy,
stats: PersistStats,
+ /// The background snapshot in flight.
+ pending: Option,
+ /// After a failed background snapshot: don't retry before the log has this many records.
+ retry_at_records: u64,
+}
+
+/// A background snapshot in flight.
+struct Pending {
+ /// The snapshot holds the state after this op.
+ seq: u64,
+ /// Log bytes / records appended since it started (all `> seq`): the log once it's done.
+ tail: Vec,
+ tail_records: u64,
+ /// Log records it compacts.
+ records: u64,
+ started: web_time::Instant,
+ /// Time the caller spent starting it.
+ start_ms: f64,
+ worker: std::thread::JoinHandle>,
}
fn io(e: std::io::Error) -> CatalogError {
@@ -195,6 +240,8 @@ impl Journal {
log_bytes: 0,
policy: SnapshotPolicy::default(),
stats: PersistStats::default(),
+ pending: None,
+ retry_at_records: 0,
};
let log = log.unwrap_or_default();
@@ -277,6 +324,10 @@ impl Journal {
buf.push('\n');
}
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
+ if let Some(p) = self.pending.as_mut() {
+ p.tail.extend_from_slice(buf.as_bytes());
+ p.tail_records += ops.len() as u64;
+ }
self.seq = seq;
self.log_records += ops.len() as u64;
self.log_bytes += buf.len() as u64;
@@ -292,6 +343,10 @@ impl Journal {
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
+ // never two snapshot writers; the one in flight is older, so it must land first
+ if let Err(e) = self.wait() {
+ log::warn!("catalog: background snapshot failed ({e}); writing one now");
+ }
let t0 = web_time::Instant::now();
let mut timing = write_snapshot(self.store.as_mut(), self.seq, catalog).map_err(io)?;
let t2 = web_time::Instant::now();
@@ -299,11 +354,103 @@ impl Journal {
self.store.write_atomic(LOG, b"").map_err(io)?;
timing.reset_ms = ms_since(t2);
timing.total_ms = ms_since(t0);
+ timing.blocking_ms = timing.total_ms;
timing.records = self.log_records;
self.record_snapshot(timing);
self.snapshot_seq = self.seq;
self.log_records = 0;
self.log_bytes = 0;
+ self.retry_at_records = 0;
+ Ok(())
+ }
+
+ /// Compact like [`Journal::snapshot`], but write the snapshot on a worker thread from a copy
+ /// of `catalog` (cheap: photos are shared) when the store supports it (else synchronously).
+ /// `catalog` must reflect every appended op. Appends go on meanwhile; [`Journal::poll`]
+ /// finishes the compaction. No-op while a snapshot is already in flight.
+ pub fn snapshot_in_background(&mut self, catalog: &Catalog) -> Result<()> {
+ if self.pending.is_some() {
+ return Ok(());
+ }
+ let t0 = web_time::Instant::now();
+ let Some(mut writer) = self.store.background_writer() else { return self.snapshot(catalog) };
+ let copy = catalog.clone();
+ let seq = self.seq;
+ let spawned = std::thread::Builder::new().name("catalog-snapshot".into()).spawn(move || write_snapshot(writer.as_mut(), seq, ©));
+ let worker = match spawned {
+ Ok(w) => w,
+ Err(e) => {
+ log::warn!("catalog: can't start a snapshot thread ({e}); writing it now");
+ return self.snapshot(catalog);
+ }
+ };
+ self.pending =
+ Some(Pending { seq, tail: Vec::new(), tail_records: 0, records: self.log_records, started: t0, start_ms: ms_since(t0), worker });
+ Ok(())
+ }
+
+ /// Finish a background snapshot if its worker is done (cheap otherwise). Returns whether one
+ /// finished. An error means the snapshot failed: nothing was lost (the log is whole), and
+ /// compaction is retried once the log has grown by another [`SnapshotPolicy::max_records`].
+ pub fn poll(&mut self) -> Result {
+ if !self.pending.as_ref().is_some_and(|p| p.worker.is_finished()) {
+ return Ok(false);
+ }
+ self.finish().map(|()| true)
+ }
+
+ /// Wait for the background snapshot in flight, if any, and finish it.
+ pub fn wait(&mut self) -> Result<()> {
+ if self.pending.is_none() {
+ return Ok(());
+ }
+ self.finish()
+ }
+
+ /// A background snapshot is in flight (until [`Journal::poll`] or [`Journal::wait`] finish it).
+ pub fn snapshot_running(&self) -> bool {
+ self.pending.is_some()
+ }
+
+ /// The background worker is done (its result not yet applied by [`Journal::poll`]).
+ pub fn snapshot_written(&self) -> bool {
+ self.pending.as_ref().is_some_and(|p| p.worker.is_finished())
+ }
+
+ /// Join the worker; on success replace the log with the records appended meanwhile.
+ fn finish(&mut self) -> Result<()> {
+ let Some(p) = self.pending.take() else { return Ok(()) };
+ let t0 = web_time::Instant::now();
+ let written = p.worker.join().unwrap_or_else(|_| Err(std::io::Error::other("snapshot thread panicked")));
+ let mut timing = match written {
+ Ok(t) => t,
+ Err(e) => {
+ // the log still holds every record: nothing lost, retry later
+ self.stats.failed_snapshots += 1;
+ self.retry_at_records = self.log_records + self.policy.max_records;
+ log::error!("catalog: background snapshot failed: {e}");
+ return Err(io(e));
+ }
+ };
+ // `catalog.snap` (op `p.seq`) is durable; the log's records `<= p.seq` are now stale.
+ // Keep exactly the ones appended since (a crash before this rewrite skips the stale ones).
+ let t1 = web_time::Instant::now();
+ self.snapshot_seq = p.seq;
+ if let Err(e) = self.store.write_atomic(LOG, &p.tail) {
+ // the log stays whole (stale records are skipped on load); compact again later
+ self.retry_at_records = self.log_records + self.policy.max_records;
+ log::error!("catalog: can't trim the log after a snapshot: {e}");
+ return Err(io(e));
+ }
+ timing.reset_ms = ms_since(t1);
+ timing.total_ms = ms_since(p.started);
+ timing.blocking_ms = p.start_ms + ms_since(t0);
+ timing.background = true;
+ timing.records = p.records;
+ self.record_snapshot(timing);
+ self.log_records = p.tail_records;
+ self.log_bytes = p.tail.len() as u64;
+ self.retry_at_records = 0;
Ok(())
}
@@ -311,22 +458,32 @@ impl Journal {
self.stats.snapshots += 1;
self.stats.last_snapshot = t;
self.stats.max_snapshot_ms = self.stats.max_snapshot_ms.max(t.total_ms);
+ self.stats.max_blocking_ms = self.stats.max_blocking_ms.max(t.blocking_ms);
if profiling() {
eprintln!(
- "catalog: snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms",
- t.records, t.bytes, t.serialize_ms, t.write_sync_ms, t.reset_ms, t.total_ms
+ "catalog: {}snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms, blocking {:.1} ms",
+ if t.background { "background " } else { "" },
+ t.records,
+ t.bytes,
+ t.serialize_ms,
+ t.write_sync_ms,
+ t.reset_ms,
+ t.total_ms,
+ t.blocking_ms
);
}
}
/// Where persistence time went so far.
pub fn stats(&self) -> PersistStats {
- self.stats
+ PersistStats { snapshot_running: self.pending.is_some(), ..self.stats }
}
/// The log is long enough to be worth compacting.
pub fn wants_snapshot(&self) -> bool {
- self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
+ self.pending.is_none()
+ && self.log_records >= self.retry_at_records
+ && (self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes)
}
pub fn seq(&self) -> u64 {
@@ -345,3 +502,16 @@ impl Journal {
self.store.describe()
}
}
+
+impl Drop for Journal {
+ /// Wait for a background snapshot (never leave a writer behind that could replace a newer
+ /// snapshot of a journal reopened on the same files). The log is left whole: the snapshot's
+ /// records in it are skipped as stale on the next load.
+ fn drop(&mut self) {
+ if let Some(p) = self.pending.take()
+ && let Ok(Err(e)) = p.worker.join()
+ {
+ log::error!("catalog: background snapshot failed: {e}");
+ }
+ }
+}
diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs
index cf7b7a232..041c9c7d0 100644
--- a/crates/catalog/src/lib.rs
+++ b/crates/catalog/src/lib.rs
@@ -622,4 +622,6 @@ impl Catalog {
#[cfg(test)]
mod tests;
#[cfg(test)]
+mod tests_background;
+#[cfg(test)]
mod tests_journal;
diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs
index 56b9eb1aa..069878c71 100644
--- a/crates/catalog/src/store.rs
+++ b/crates/catalog/src/store.rs
@@ -34,6 +34,12 @@ pub trait Store: Send {
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
/// Human-readable location (diagnostics).
fn describe(&self) -> String;
+ /// Another handle on the same files that a worker thread can write `catalog.snap` through
+ /// while this one keeps appending to the log (background compaction). `None` (the default):
+ /// snapshots are written synchronously.
+ fn background_writer(&self) -> Option> {
+ None
+ }
}
/// Files in a directory.
@@ -142,6 +148,11 @@ impl Store for FsStore {
fn describe(&self) -> String {
self.dir.display().to_string()
}
+
+ #[cfg(not(target_arch = "wasm32"))]
+ fn background_writer(&self) -> Option> {
+ Some(Box::new(FsStore { dir: self.dir.clone(), appender: None }))
+ }
}
/// Counts the bytes written through it.
@@ -165,6 +176,8 @@ impl io::Write for Counter {
#[derive(Clone, Default)]
pub struct MemStore {
pub files: Arc>>>,
+ /// Offer a [`Store::background_writer`] (off by default: snapshots stay synchronous).
+ pub background: bool,
}
impl MemStore {
@@ -200,4 +213,7 @@ impl Store for MemStore {
fn describe(&self) -> String {
"memory".into()
}
+ fn background_writer(&self) -> Option> {
+ self.background.then(|| Box::new(self.clone()) as Box)
+ }
}
diff --git a/crates/catalog/src/tests_background.rs b/crates/catalog/src/tests_background.rs
new file mode 100644
index 000000000..0e06936e0
--- /dev/null
+++ b/crates/catalog/src/tests_background.rs
@@ -0,0 +1,338 @@
+//! Background compaction (issue #37): a snapshot written by a worker while appends go on.
+//! "Crashes" are simulated by copying the files at a given moment and loading the copy: every
+//! acknowledged (appended) op must be there, in order, whatever the snapshot was doing.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Condvar, Mutex};
+use std::time::Duration;
+
+use proptest::prelude::*;
+
+use crate::journal::{LOG, SNAPSHOT};
+use crate::*;
+
+/// A MemStore whose snapshot writes wait for a gate (to hold a background snapshot mid-flight)
+/// and can be made to fail.
+#[derive(Clone)]
+struct Gated {
+ files: MemStore,
+ gate: Arc<(Mutex, Condvar)>,
+ fail: Arc,
+}
+
+impl Gated {
+ fn new() -> Gated {
+ Gated { files: MemStore::new(), gate: Arc::new((Mutex::new(false), Condvar::new())), fail: Arc::new(AtomicBool::new(false)) }
+ }
+ fn set_gate(&self, open: bool) {
+ *self.gate.0.lock().unwrap() = open;
+ self.gate.1.notify_all();
+ }
+ /// The files as a crash at this moment would leave them.
+ fn crash(&self) -> MemStore {
+ let m = MemStore::new();
+ *m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
+ m
+ }
+ fn snapshot_seq_on_disk(&self) -> Option {
+ let v: serde_json::Value = serde_json::from_slice(&self.files.get(SNAPSHOT)?).ok()?;
+ v["seq"].as_u64()
+ }
+}
+
+impl Store for Gated {
+ fn read(&mut self, name: &str) -> std::io::Result