From 45cba87643e4a96a9ed74af36c4e440fd0901aed Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:25:01 -0400 Subject: [PATCH 1/2] fix(rvf-runtime): clear the deletion bit when an id is re-ingested delete(ids) sets bits in the deletion bitmap and ingest_batch never cleared them, so re-inserting a deleted id left it tombstoned. query() then skips the id, making the newly written vector unreachable, and compact() reads the stale bit as dead and physically removes the vector, destroying a payload that was written after the deletion. DeletionBitmap::clear_ids already existed for this; its only caller was its own unit test. The call goes immediately after the insert loop, which is where the id becomes live again. Adds two regression tests, one per consequence. Both were observed failing before the fix and passing after, and removing the added line again turns exactly those two red while the other 243 stay green. --- crates/rvf/rvf-runtime/src/store.rs | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/rvf/rvf-runtime/src/store.rs b/crates/rvf/rvf-runtime/src/store.rs index 3f1c56bdd8..a2aaf6bacc 100644 --- a/crates/rvf/rvf-runtime/src/store.rs +++ b/crates/rvf/rvf-runtime/src/store.rs @@ -403,6 +403,12 @@ impl RvfStore { self.vectors.insert_slice(vec_id, vec_data); } + // Re-inserting an id resurrects it. A deletion tombstones the payload that + // was there, it does not retire the identifier, so the bit must not outlive + // the vector it condemned. Left set, it hides the new vector from every + // query, and compact() reads it as dead and physically drops it. + self.deletion_bitmap.clear_ids(&valid_ids); + // Keep the in-memory HNSW index in sync with the new vectors. { let mut guard = self.index.lock().unwrap_or_else(|e| e.into_inner()); @@ -3382,6 +3388,79 @@ mod tests { store.close().unwrap(); } + #[test] + fn reingest_after_delete_makes_the_id_visible_again() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("reingest.rvf"); + + let options = RvfOptions { + dimension: 2, + metric: DistanceMetric::L2, + ..Default::default() + }; + let mut store = RvfStore::create(&path, options).unwrap(); + + let original = vec![1.0f32, 0.0]; + store.ingest_batch(&[&original[..]], &[42], None).unwrap(); + assert_eq!(store.delete(&[42]).unwrap().deleted, 1); + + // Re-inserting the same id must resurrect it: a deletion is a statement + // about the old payload, not a permanent ban on the identifier. + let replacement = vec![0.0f32, 1.0]; + store + .ingest_batch(&[&replacement[..]], &[42], None) + .unwrap(); + + let hits = store + .query(&[0.0, 1.0], 1, &QueryOptions::default()) + .unwrap(); + assert!( + hits.iter().any(|r| r.id == 42), + "id 42 stayed soft-deleted after re-ingest, so the new payload is unreachable" + ); + + store.close().unwrap(); + } + + #[test] + fn compact_keeps_a_vector_reingested_after_delete() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("reingest_compact.rvf"); + + let options = RvfOptions { + dimension: 2, + metric: DistanceMetric::L2, + ..Default::default() + }; + let mut store = RvfStore::create(&path, options).unwrap(); + + let keep = vec![1.0f32, 0.0]; + let original = vec![0.0f32, 1.0]; + store + .ingest_batch(&[&keep[..], &original[..]], &[1, 42], None) + .unwrap(); + store.delete(&[42]).unwrap(); + + let replacement = vec![0.5f32, 0.5]; + store + .ingest_batch(&[&replacement[..]], &[42], None) + .unwrap(); + + // Compaction physically drops whatever the bitmap still calls dead, so a + // stale bit here destroys live data rather than merely hiding it. + store.compact().unwrap(); + + let hits = store + .query(&[0.5, 0.5], 10, &QueryOptions::default()) + .unwrap(); + assert!( + hits.iter().any(|r| r.id == 42), + "compaction dropped a vector that had been re-ingested after deletion" + ); + + store.close().unwrap(); + } + #[test] fn filter_query() { let dir = TempDir::new().unwrap(); From 998771e8dc82c843d426a93cf86845cf7f71d0de Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:57:12 -0400 Subject: [PATCH 2/2] test(rvf-runtime): pin persisted tombstone clear across store reopen Add a regression test that ingests id 42, deletes it, re-ingests it with a distinguishable replacement vector, closes the store, reopens it from disk, and asserts the query returns the replacement rather than a soft-deleted (or stale) result. The two existing re-ingest tests only covered the in-memory and compaction paths; neither closed and reopened the store, so the persisted deleted_ids manifest snapshot was unpinned. Co-Authored-By: Claude Sonnet 5 --- crates/rvf/rvf-runtime/src/store.rs | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/rvf/rvf-runtime/src/store.rs b/crates/rvf/rvf-runtime/src/store.rs index a2aaf6bacc..2e769d4c80 100644 --- a/crates/rvf/rvf-runtime/src/store.rs +++ b/crates/rvf/rvf-runtime/src/store.rs @@ -3461,6 +3461,57 @@ mod tests { store.close().unwrap(); } + #[test] + fn reingest_after_delete_survives_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("reingest_reopen.rvf"); + + let options = RvfOptions { + dimension: 2, + metric: DistanceMetric::L2, + ..Default::default() + }; + + { + let mut store = RvfStore::create(&path, options).unwrap(); + + let original = vec![1.0f32, 0.0]; + store.ingest_batch(&[&original[..]], &[42], None).unwrap(); + assert_eq!(store.delete(&[42]).unwrap().deleted, 1); + + let replacement = vec![0.0f32, 1.0]; + store + .ingest_batch(&[&replacement[..]], &[42], None) + .unwrap(); + + store.close().unwrap(); + } + + // Reopen from the same directory: the deletion bit that was cleared + // in memory must also be cleared in the persisted manifest, or boot + // reconstructs id 42 as deleted again and buries the replacement. + { + let store = RvfStore::open(&path).unwrap(); + + let hits = store + .query(&[0.0, 1.0], 1, &QueryOptions::default()) + .unwrap(); + assert_eq!( + hits.len(), + 1, + "id 42 came back soft-deleted after reopen, so the persisted \ + tombstone clear did not survive" + ); + assert_eq!(hits[0].id, 42); + assert!( + hits[0].distance < f32::EPSILON, + "id 42 resolved to a vector other than the post-reingest replacement" + ); + + store.close().unwrap(); + } + } + #[test] fn filter_query() { let dir = TempDir::new().unwrap();