fix: ADR-0006 — clear redo when new work commits without a snapshot
/runda found silent data loss: with the non-fatal snapshot-failure policy, a committed mutation whose snapshot couldn't be staged left the redo stack stale (redo-clear was only a side effect of finalize), so a later redo silently discarded the new work. Same gap in batches. - SnapshotStore::clear_redo() drops the redo stack + payloads - snapshot_then / end_batch call it when committed user work has no staged snapshot; for disk-full it succeeds where a full backup couldn't (tiny index write + payload deletes) - unit test + integration regression (forced staging failure) - ADR-0006 implementation note records the fix + residual edge 1698 passed / 0 failed / 1 ignored; clippy clean.
This commit is contained in:
+50
@@ -294,6 +294,27 @@ impl SnapshotStore {
|
||||
Ok(Some(entry.meta()))
|
||||
}
|
||||
|
||||
/// Clear the redo stack (deleting its payloads) without touching
|
||||
/// the undo ring. Called when new work commits but its snapshot
|
||||
/// could not be staged: `finalize` (which normally clears redo)
|
||||
/// never runs, so the redo entries are left stale — a later
|
||||
/// `redo` would restore an outdated state and silently discard
|
||||
/// the new work. Clearing redo here closes that data-loss hole
|
||||
/// (ADR-0006 Amendment 1; the snapshot-failure policy is
|
||||
/// non-fatal, so this keeps it *safe*).
|
||||
pub fn clear_redo(&self) -> Result<()> {
|
||||
let mut index = self.load_index()?;
|
||||
if index.redo.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for r in std::mem::take(&mut index.redo) {
|
||||
remove_dir_all_if_exists(&self.payload_dir(r.id))?;
|
||||
}
|
||||
self.save_index(&index)?;
|
||||
tracing::debug!("redo stack cleared (new work committed without a snapshot)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove crash leftovers on project open: the `.staging/` dir and
|
||||
/// any payload dir not referenced by the index.
|
||||
pub fn cleanup(&self) -> Result<()> {
|
||||
@@ -721,6 +742,35 @@ mod tests {
|
||||
assert_eq!(payload_dirs, 2, "ring capped at 2 payloads");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_redo_drops_redo_without_touching_undo() {
|
||||
let fx = Fixture::new();
|
||||
let store = fx.store();
|
||||
// Two entries so one survives in the undo ring after undo.
|
||||
stage_finalize(&store, &fx.conn, "cmd 1");
|
||||
stage_finalize(&store, &fx.conn, "cmd 2");
|
||||
let mut conn = fx.conn;
|
||||
store.undo(&mut conn).unwrap(); // pops cmd 2, redo gets it
|
||||
assert!(store.peek_redo().unwrap().is_some(), "redo available");
|
||||
assert_eq!(store.peek_undo().unwrap().unwrap().command, "cmd 1");
|
||||
|
||||
store.clear_redo().unwrap();
|
||||
assert!(store.peek_redo().unwrap().is_none(), "redo cleared");
|
||||
// The remaining undo entry is untouched.
|
||||
assert_eq!(
|
||||
store.peek_undo().unwrap().unwrap().command,
|
||||
"cmd 1",
|
||||
"undo ring intact"
|
||||
);
|
||||
// The redo payload is gone; only the surviving undo payload remains.
|
||||
let payload_dirs = fs::read_dir(&store.root)
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
|
||||
.count();
|
||||
assert_eq!(payload_dirs, 1, "only the surviving undo payload remains");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stacks_return_none() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
Reference in New Issue
Block a user