#![cfg(feature = "persistence")]
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use serde_json::json;
use tempfile::TempDir;
use velesdb_core::distance::DistanceMetric;
use velesdb_core::quantization::StorageMode;
use velesdb_core::{Point, VectorCollection};
fn make_vector(seed: u64, dimension: usize) -> Vec<f32> {
#[allow(clippy::cast_precision_loss)] (0..dimension)
.map(|i| ((seed as f32) * 0.3 + (i as f32) * 0.1).sin())
.collect()
}
fn make_points(n: u64, dimension: usize) -> Vec<Point> {
(0..n)
.map(|id| {
Point::new(
id,
make_vector(id, dimension),
Some(json!({ "title": format!("document {id}"), "rank": id })),
)
})
.collect()
}
fn create_collection(dir: &std::path::Path, dimension: usize) -> VectorCollection {
VectorCollection::create(
dir.to_path_buf(),
"batch_delete_test",
dimension,
DistanceMetric::Euclidean,
StorageMode::Full,
)
.expect("create collection")
}
#[test]
fn batch_delete_persists_across_reopen() {
let dir = TempDir::new().expect("tempdir");
let coll_dir = dir.path().join("coll");
let dimension = 8;
let total: u64 = 200;
let deleted: Vec<u64> = (0..total / 2).collect();
let survivors: Vec<u64> = (total / 2..total).collect();
{
let coll = create_collection(&coll_dir, dimension);
coll.upsert_bulk(&make_points(total, dimension))
.expect("upsert");
coll.flush().expect("flush after upsert");
coll.delete(&deleted).expect("batch delete");
}
let coll = VectorCollection::open(coll_dir).expect("reopen");
for (id, got) in deleted.iter().zip(coll.get(&deleted)) {
assert!(got.is_none(), "deleted point {id} resurrected after reopen");
}
for (id, got) in survivors.iter().copied().zip(coll.get(&survivors)) {
let point = got.unwrap_or_else(|| panic!("survivor {id} lost after reopen"));
assert_eq!(
point.vector,
make_vector(id, dimension),
"vector of {id} changed"
);
let payload = point.payload.expect("survivor payload lost");
assert_eq!(payload["rank"], json!(id), "payload of {id} changed");
}
assert_eq!(
coll.all_point_ids(),
survivors,
"storage id set diverged after reopen"
);
}
const CONCURRENT_READ_BOUND: Duration = Duration::from_secs(5);
const WATCHDOG: Duration = Duration::from_secs(300);
#[test]
fn concurrent_reader_completes_during_batch_delete() {
let dir = TempDir::new().expect("tempdir");
let coll_dir = dir.path().join("coll");
let dimension = 16;
let total: u64 = 8000;
let keeper = total - 1;
let victims: Vec<u64> = (0..keeper).collect();
let coll = create_collection(&coll_dir, dimension);
coll.upsert_bulk(&make_points(total, dimension))
.expect("upsert");
coll.flush().expect("flush after upsert");
let coll = Arc::new(coll);
let deleter = {
let coll = Arc::clone(&coll);
thread::spawn(move || {
let started = Instant::now();
coll.delete(&victims).expect("batch delete");
started.elapsed()
})
};
let (tx, rx) = mpsc::channel();
let reader = {
let coll = Arc::clone(&coll);
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
let started = Instant::now();
let got = coll.get(&[keeper]);
let latency = started.elapsed();
tx.send((latency, got[0].is_some())).expect("send result");
})
};
let (read_latency, keeper_visible) = rx
.recv_timeout(WATCHDOG)
.expect("concurrent reader wedged behind batch delete (watchdog hit)");
let delete_duration = deleter.join().expect("deleter thread panicked");
reader.join().expect("reader thread panicked");
eprintln!(
"batch delete of {keeper} points: {delete_duration:?}; \
concurrent get latency: {read_latency:?}"
);
assert!(keeper_visible, "surviving point invisible during delete");
assert!(
read_latency < CONCURRENT_READ_BOUND,
"concurrent reader took {read_latency:?} (bound {CONCURRENT_READ_BOUND:?}) — \
batch delete is holding write locks across per-point fsyncs again"
);
}