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:
claude@clouddev1
2026-05-24 21:10:44 +00:00
parent 5442cfc0b9
commit df6aa69155
4 changed files with 123 additions and 11 deletions
+37
View File
@@ -389,6 +389,43 @@ fn undo_restores_db_and_csv_consistently() {
});
}
#[test]
fn redo_is_cleared_when_new_work_commits_without_a_snapshot() {
// Regression for a /runda finding: with the non-fatal
// snapshot-failure policy, a committed mutation whose snapshot
// can't be staged left the redo stack stale — a later `redo`
// would silently discard the new work. Any committed user work
// must clear redo, even when no snapshot was recorded.
let data = tempdir();
let (_p, db, path) = open_project(&data, true);
rt().block_on(async {
make_t(&db).await;
dsl_insert(&db, 1, 10).await;
sql_delete(&db, "delete from T where id = 1").await; // → 0 rows
db.undo().await.unwrap(); // redo now holds the delete; → 1 row
assert!(db.peek_redo().await.unwrap().is_some(), "redo populated");
});
// Force the next staging to fail while the rest of the ring stays
// writable: a plain file where the `.staging` dir is expected makes
// `stage` error, but `clear_redo` (index + payload deletes in the
// ring root) still succeeds.
let staging = path.join(".snapshots").join(".staging");
std::fs::write(&staging, b"block").unwrap();
rt().block_on(async {
dsl_insert(&db, 2, 20).await; // commits; snapshot staging fails
assert_eq!(count_t(&db).await, 2, "new work applied");
assert!(
db.peek_redo().await.unwrap().is_none(),
"stale redo must be cleared when new work commits without a snapshot"
);
// Redo is now a no-op — it cannot resurrect the discarded state.
assert!(db.redo().await.unwrap().is_none());
assert_eq!(count_t(&db).await, 2, "new work preserved");
});
}
#[test]
fn undo_ring_persists_across_reopen() {
let data = tempdir();