Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions crates/rvf/rvf-runtime/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -3382,6 +3388,130 @@ 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 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();
Expand Down
Loading