Files
lightcraft/crates/engine/src/tests_persist.rs
T
Brandon ThomasandClaude Opus 5.5 08c2acda26 Issue #101: catalog.log: cut a partial append back before retrying
A failed append (disk full, dropped share) could leave part of its batch in
catalog.log; the retry (same seqs) then shared a line with the fragment and
the next load treated it as mid-log damage, setting later edits aside.

- Journal::append cuts the log back to the last whole record on error (or,
  if that fails too, before the next append).
- FsStore drops a failed append handle so the next attempt reopens the file;
  truncate of a never-created file is a no-op.
- The loader recognises a CRC-valid record glued after a fragment (logs
  written by older builds) and skips only the fragment.
- log_bytes now counts the newline added to an unterminated last record.
- close_library writes the closing snapshot even when appending fails; the
  snapshot absorbs the queued ops (Journal::snapshot_with_unlogged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:13:00 -04:00

194 lines
7.8 KiB
Rust

//! A failed save is an error, not a silent success: a command whose journal append fails returns
//! [`EngineError::NotSaved`], its change stays applied and queued, and the next save writes it.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use lightcraft_catalog::{Flag, MemStore, SnapshotPolicy, Store};
use serde_json::json;
use crate::library::LibraryStores;
use crate::{EngineError, Session};
/// A store whose writes fail while `fail` is set (a full or unplugged disk).
#[derive(Clone, Default)]
struct Flaky {
files: MemStore,
fail: Arc<AtomicBool>,
/// Only snapshots fail (appends work).
snapshots_only: bool,
/// Only appends fail (a dead log handle; fresh files can still be written).
appends_only: bool,
}
impl Flaky {
fn failing(&self) -> bool {
self.fail.load(Ordering::SeqCst)
}
fn set_failing(&self, on: bool) {
self.fail.store(on, Ordering::SeqCst);
}
/// The files as a crash at this moment would leave them.
fn crash_copy(&self) -> Flaky {
let m = MemStore::new();
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
Flaky { files: m, ..Default::default() }
}
}
impl Store for Flaky {
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
self.files.read(name)
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
if self.failing() && !self.appends_only {
return Err(std::io::Error::other("disk full"));
}
self.files.write_atomic(name, data)
}
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
if self.failing() && !self.snapshots_only {
return Err(std::io::Error::other("disk full"));
}
self.files.append(name, data)
}
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
self.files.truncate(name, len)
}
fn describe(&self) -> String {
"flaky".into()
}
}
fn open(store: &Flaky, seed: bool) -> Session {
let mut s = Session::new();
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
s.open_library_in(stores, seed).unwrap();
s
}
#[test]
fn failed_append_fails_the_command_and_is_retried() {
let store = Flaky::default();
let mut s = open(&store, true);
let id = s.selection.active.unwrap();
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
store.set_failing(true);
let e = s.execute("photo.rate", &json!({"rating": 4})).unwrap_err();
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
let msg = e.to_string();
assert!(
msg.starts_with("saved in memory but not written to disk: ") && msg.contains("disk full") && msg.ends_with("LightCraft will retry"),
"{msg}"
);
// applied in memory, undoable, queued
assert_eq!(s.catalog.photo(id).unwrap().rating, 4);
assert!(!s.undo.is_empty());
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
// queries and commands that change nothing are unaffected (they retry the queue, but report
// their own result)
let info = s.execute("library.info", &json!({})).unwrap();
assert_eq!(info["unsavedOps"], 1, "{info}");
assert!(info["unsavedError"].as_str().unwrap().contains("disk full"), "{info}");
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
// a second change fails too; both stay queued, in order
let e = s.execute("photo.flag", &json!({"flag": "pick"})).unwrap_err();
assert!(matches!(e, EngineError::NotSaved(_)));
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
// the frame loop backs off instead of retrying every frame
s.persist_if_dirty();
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
// a crash now loses only the unsaved changes; the earlier ones are on disk
assert_eq!(open(&store.crash_copy(), true).catalog.photo(id).unwrap().rating, 1);
// the disk comes back: the next save writes the whole queue
store.set_failing(false);
s.execute("library.info", &json!({})).unwrap();
assert_eq!(s.unsaved(), None);
assert!(s.library.as_ref().unwrap().last_error.is_none());
let expect = s.catalog.to_snapshot();
drop(s); // no close: like a crash
let s2 = open(&store, true);
assert_eq!(s2.catalog.to_snapshot(), expect);
let p = s2.catalog.photo(id).unwrap();
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
}
/// Undo is a command like any other: a failed write of its op is reported, and retried.
#[test]
fn failed_append_of_undo_is_reported_and_retried() {
let store = Flaky::default();
let mut s = open(&store, true);
let id = s.selection.active.unwrap();
let before = s.catalog.photo(id).unwrap().rating;
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
store.set_failing(true);
assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::NotSaved(_))));
assert_eq!(s.catalog.photo(id).unwrap().rating, before);
store.set_failing(false);
s.persist().unwrap();
drop(s);
assert_eq!(open(&store, true).catalog.photo(id).unwrap().rating, before);
}
/// A failed compaction is not a failed command: the command's own records were appended.
#[test]
fn failed_snapshot_does_not_fail_the_command() {
let store = Flaky { snapshots_only: true, ..Default::default() };
let mut s = open(&store, false);
s.library.as_mut().unwrap().journal_mut().policy = SnapshotPolicy { max_records: 1, max_bytes: u64::MAX };
store.set_failing(true);
s.execute("album.create", &json!({"name": "A"})).unwrap();
s.execute("album.create", &json!({"name": "B"})).unwrap();
assert_eq!(s.unsaved(), None);
assert!(s.library.as_ref().unwrap().last_error.is_some(), "the compaction failure is reported");
drop(s);
store.set_failing(false);
assert_eq!(open(&store, false).catalog.albums().count(), 2);
}
/// Issue #101: quitting while the log can't be appended to still saves the queued changes in the
/// closing snapshot (a fresh file), and reports success; they are not appended again later.
#[test]
fn close_saves_queued_changes_in_the_snapshot_when_appends_fail() {
let store = Flaky { appends_only: true, ..Default::default() };
let mut s = open(&store, true);
let id = s.selection.active.unwrap();
store.set_failing(true);
assert!(matches!(s.execute("photo.rate", &json!({"rating": 4})), Err(EngineError::NotSaved(_))));
assert!(matches!(s.execute("photo.flag", &json!({"flag": "pick"})), Err(EngineError::NotSaved(_))));
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
s.close_library().unwrap();
assert_eq!(s.unsaved(), None);
assert!(s.pending_log.is_empty());
let expect = s.catalog.to_snapshot();
drop(s);
store.set_failing(false);
let mut s2 = open(&store, true);
assert_eq!(s2.catalog.to_snapshot(), expect);
let p = s2.catalog.photo(id).unwrap();
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
// later edits continue after the absorbed ops
s2.execute("photo.rate", &json!({"rating": 2})).unwrap();
let expect = s2.catalog.to_snapshot();
drop(s2);
assert_eq!(open(&store, true).catalog.to_snapshot(), expect);
}
/// When neither the log nor the snapshot can be written, closing fails with `NotSaved` and the
/// changes stay queued (a later retry can still save them).
#[test]
fn close_fails_when_nothing_can_be_saved() {
let store = Flaky::default();
let mut s = open(&store, true);
store.set_failing(true);
assert!(s.execute("photo.rate", &json!({"rating": 4})).is_err());
let e = s.close_library().unwrap_err();
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
store.set_failing(false);
s.close_library().unwrap();
assert_eq!(s.unsaved(), None);
}