diff --git a/crates/engine/src/log/mod.rs b/crates/engine/src/log/mod.rs index 4a73c8d..dc08089 100644 --- a/crates/engine/src/log/mod.rs +++ b/crates/engine/src/log/mod.rs @@ -1882,6 +1882,56 @@ mod value_sep_tests { assert_eq!(log.values.blob_count(), 1, "only v2's blob remains"); }); } + + /// Crash after VALUE_SEP then inline overwrite: both records are in the + /// unsealed active file. Replay must clear the sidecar the same way the + /// live write path does, or `sweep_orphans` keeps the large blob and the + /// next seal writes the stale hash. + #[test] + fn crash_after_large_then_small_must_not_leak_blob() { + run(async { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_path_buf(); + let cfg = LogConfig { + rotate_threshold: 1 << 30, + fanout: 8, + value_sep_threshold: 4096, + }; + { + let log = NamespaceLog::open(path.clone(), cfg).await.unwrap(); + let big = vec![0xABu8; 64 * 1024]; + log.put_full(key(0), &big, &[], None).await.unwrap(); + log.put_full(key(0), b"inline-after-large", &[], None) + .await + .unwrap(); + assert_eq!( + log.index.borrow().valsep(b"k00000"), + None, + "live path clears valsep on inline overwrite" + ); + assert_eq!(log.values.blob_count(), 0); + } + + let log = NamespaceLog::open(path, cfg).await.unwrap(); + let entry = *log + .index + .borrow() + .get(b"k00000") + .expect("inline key must survive reopen"); + let (v, _) = log.read_value(entry).await.unwrap(); + assert_eq!(&v[..], b"inline-after-large"); + assert_eq!( + log.index.borrow().valsep(b"k00000"), + None, + "replay must clear valsep on inline overwrite" + ); + assert_eq!( + log.values.blob_count(), + 0, + "stale VALUE_SEP blob must be swept" + ); + }); + } } #[cfg(test)] diff --git a/crates/engine/src/log/recover.rs b/crates/engine/src/log/recover.rs index 11a4e75..34425af 100644 --- a/crates/engine/src/log/recover.rs +++ b/crates/engine/src/log/recover.rs @@ -276,5 +276,10 @@ fn apply_record( "value-separated record without a 16-byte hash; ignoring sidecar entry" ); } + } else { + // Match the live write path (`apply_valsep_insert`): an inline + // overwrite must drop the previous VALUE_SEP hash so reopen + + // sweep_orphans does not resurrect the blob. + index.set_valsep(&key_bytes, None); } }