mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
- catalog: Store trait (FsStore: temp+fsync+rename+dir fsync; MemStore), Journal: CRC-checked JSON-lines op log with sequence numbers + atomic snapshots; torn final record dropped and truncated, stale records after a snapshot skipped, mid-log damage preserved as catalog.log.damaged-N. - PushHistory op so each edit logs one history step (not the full history). - engine: Session::open_library/persist/close_library; every top-level command is fsynced before returning; presets.json + view.json; library.info / library.compact commands. - app: default library ~/Pictures/LightCraft Library (LIGHTCRAFT_LIBRARY or --library DIR override), --memory for throwaway sessions, snapshot on quit. - tests: proptest replay(log)==state with random snapshots/reopens, every truncation of the last record, stale log, mid-log damage, FsStore, engine restart without close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
9.2 KiB
Rust
241 lines
9.2 KiB
Rust
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
|
|
//!
|
|
//! Files (in a [`Store`]):
|
|
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
|
|
//! state after op `N`; replaced atomically (temp + fsync + rename).
|
|
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
|
|
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
|
|
//! fsynced before [`Journal::append`] returns.
|
|
//!
|
|
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
|
|
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
|
|
//! and the file is cut back to the last good record, so later appends start on a clean line;
|
|
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
|
|
//! 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-<seq>`, and a fresh snapshot is written so the library stays usable.
|
|
|
|
use crate::store::Store;
|
|
use crate::{Catalog, CatalogError, Op, Result};
|
|
|
|
pub const SNAPSHOT: &str = "catalog.snap";
|
|
pub const LOG: &str = "catalog.log";
|
|
const FORMAT: &str = "lightcraft-catalog";
|
|
const VERSION: u32 = 1;
|
|
|
|
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct SnapshotPolicy {
|
|
pub max_records: u64,
|
|
pub max_bytes: u64,
|
|
}
|
|
|
|
impl Default for SnapshotPolicy {
|
|
fn default() -> Self {
|
|
SnapshotPolicy { max_records: 2000, max_bytes: 16 << 20 }
|
|
}
|
|
}
|
|
|
|
/// What happened while loading.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct LoadReport {
|
|
/// `seq` of the snapshot that was loaded (0 = none).
|
|
pub snapshot_seq: u64,
|
|
/// Log records applied on top of the snapshot.
|
|
pub replayed: usize,
|
|
/// Records skipped because the snapshot already contained them.
|
|
pub stale: usize,
|
|
/// Records whose op failed to apply (should never happen; reported, not fatal).
|
|
pub failed: usize,
|
|
/// Bytes of a torn final record that were dropped.
|
|
pub torn_bytes: u64,
|
|
/// The damaged log was preserved under this name.
|
|
pub damaged: Option<String>,
|
|
/// No catalog files existed (a new library).
|
|
pub created: bool,
|
|
}
|
|
|
|
pub struct Journal {
|
|
store: Box<dyn Store>,
|
|
/// `seq` of the last durable op.
|
|
seq: u64,
|
|
snapshot_seq: u64,
|
|
log_records: u64,
|
|
log_bytes: u64,
|
|
pub policy: SnapshotPolicy,
|
|
}
|
|
|
|
fn io(e: std::io::Error) -> CatalogError {
|
|
CatalogError::Io(e.to_string())
|
|
}
|
|
|
|
/// One log line (without the newline) for op number `seq`.
|
|
pub fn encode_record(seq: u64, op: &Op) -> String {
|
|
let body = serde_json::to_string(op).unwrap_or_default();
|
|
let crc = crc32fast::hash(body.as_bytes());
|
|
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
|
|
}
|
|
|
|
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
|
|
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
|
let rest = line.strip_prefix("{\"seq\":")?;
|
|
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
|
let (crc, rest) = rest.split_once(",\"op\":")?;
|
|
let body = rest.strip_suffix('}')?;
|
|
let seq: u64 = seq.parse().ok()?;
|
|
let crc: u32 = crc.parse().ok()?;
|
|
if crc32fast::hash(body.as_bytes()) != crc {
|
|
return None;
|
|
}
|
|
serde_json::from_str(body).ok().map(|op| (seq, op))
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SnapFile {
|
|
format: String,
|
|
version: u32,
|
|
seq: u64,
|
|
catalog: Catalog,
|
|
}
|
|
|
|
impl Journal {
|
|
/// Open (or create) the catalog in `store`: load the snapshot, replay the log, repair a torn
|
|
/// tail. Fails only if the snapshot itself is unreadable (then nothing is modified).
|
|
pub fn open(mut store: Box<dyn Store>) -> Result<(Journal, Catalog, LoadReport)> {
|
|
let mut report = LoadReport::default();
|
|
let snap = store.read(SNAPSHOT).map_err(io)?;
|
|
let log = store.read(LOG).map_err(io)?;
|
|
report.created = snap.is_none() && log.is_none();
|
|
let (mut catalog, snapshot_seq) = match snap {
|
|
Some(bytes) => {
|
|
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
|
if s.format != FORMAT || s.version > VERSION {
|
|
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
|
|
}
|
|
(s.catalog, s.seq)
|
|
}
|
|
None => (Catalog::new(), 0),
|
|
};
|
|
report.snapshot_seq = snapshot_seq;
|
|
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
|
|
let log = log.unwrap_or_default();
|
|
|
|
// Scan records; `good_end` is the byte offset just past the last good record.
|
|
let mut pos = 0usize;
|
|
let mut good_end = 0usize;
|
|
let mut damaged_at: Option<usize> = None;
|
|
let mut needs_newline = false;
|
|
while pos < log.len() {
|
|
let (line_end, next, has_nl) = match log[pos..].iter().position(|b| *b == b'\n') {
|
|
Some(i) => (pos + i, pos + i + 1, true),
|
|
None => (log.len(), log.len(), false),
|
|
};
|
|
let line = std::str::from_utf8(&log[pos..line_end]).ok().map(|l| l.trim_end_matches('\r'));
|
|
if line.is_some_and(|l| l.trim().is_empty()) {
|
|
pos = next;
|
|
good_end = next;
|
|
continue;
|
|
}
|
|
match line.and_then(decode_record) {
|
|
Some((seq, op)) if seq <= j.seq => {
|
|
// already in the snapshot
|
|
let _ = op;
|
|
report.stale += 1;
|
|
}
|
|
Some((seq, op)) if seq == j.seq + 1 => {
|
|
if catalog.apply(op).is_err() {
|
|
report.failed += 1;
|
|
} else {
|
|
report.replayed += 1;
|
|
}
|
|
j.seq = seq;
|
|
}
|
|
_ => {
|
|
// bad record (or a gap): torn tail if nothing good follows, else damage
|
|
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
|
|
if rest_has_good {
|
|
damaged_at = Some(pos);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
j.log_records += 1;
|
|
pos = next;
|
|
good_end = next;
|
|
needs_newline = !has_nl;
|
|
}
|
|
|
|
if let Some(at) = damaged_at {
|
|
let name = format!("{LOG}.damaged-{}", j.seq);
|
|
j.store.write_atomic(&name, &log).map_err(io)?;
|
|
log::warn!("catalog log damaged at byte {at}; kept as {name}; state recovered up to op {}", j.seq);
|
|
report.damaged = Some(name);
|
|
j.snapshot(&catalog)?;
|
|
} else {
|
|
if good_end < log.len() {
|
|
report.torn_bytes = (log.len() - good_end) as u64;
|
|
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
|
|
j.store.truncate(LOG, good_end as u64).map_err(io)?;
|
|
} else if needs_newline {
|
|
j.store.append(LOG, b"\n").map_err(io)?;
|
|
}
|
|
j.log_bytes = good_end as u64;
|
|
}
|
|
catalog.revision = 0;
|
|
Ok((j, catalog, report))
|
|
}
|
|
|
|
/// Append ops (already applied to the live catalog) durably, in order.
|
|
pub fn append(&mut self, ops: &[Op]) -> Result<()> {
|
|
if ops.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let mut buf = String::new();
|
|
let mut seq = self.seq;
|
|
for op in ops {
|
|
seq += 1;
|
|
buf.push_str(&encode_record(seq, op));
|
|
buf.push('\n');
|
|
}
|
|
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
|
self.seq = seq;
|
|
self.log_records += ops.len() as u64;
|
|
self.log_bytes += buf.len() as u64;
|
|
Ok(())
|
|
}
|
|
|
|
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
|
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
|
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
|
|
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
|
|
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
|
|
// A crash here leaves old records in the log; they are skipped by seq on load.
|
|
self.store.write_atomic(LOG, b"").map_err(io)?;
|
|
self.snapshot_seq = self.seq;
|
|
self.log_records = 0;
|
|
self.log_bytes = 0;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
pub fn seq(&self) -> u64 {
|
|
self.seq
|
|
}
|
|
pub fn snapshot_seq(&self) -> u64 {
|
|
self.snapshot_seq
|
|
}
|
|
pub fn log_records(&self) -> u64 {
|
|
self.log_records
|
|
}
|
|
pub fn log_bytes(&self) -> u64 {
|
|
self.log_bytes
|
|
}
|
|
pub fn describe(&self) -> String {
|
|
self.store.describe()
|
|
}
|
|
}
|