use super::recovery;
use crate::collection::Collection;
use crate::distance::DistanceMetric;
use crate::index::VectorIndex;
use crate::point::Point;
use crate::storage::VectorStorage;
use std::path::PathBuf;
fn make_points(n: u64) -> Vec<Point> {
(0..n)
.map(|i| {
let v = f32::from(u16::try_from(i).expect("test ID fits u16"));
Point::without_payload(i, vec![v, v + 1.0, v + 2.0, v + 3.0])
})
.collect()
}
#[test]
fn test_no_gap_returns_zero() {
let temp = tempfile::tempdir().expect("temp dir");
let coll =
Collection::create(PathBuf::from(temp.path()), 4, DistanceMetric::Cosine).expect("create");
coll.upsert(make_points(5)).expect("upsert");
let recovered =
recovery::recover_hnsw_gap(&coll.storage.vector_storage, &coll.storage.index, 4)
.expect("recovery");
assert_eq!(recovered, 0);
}
#[test]
fn test_empty_collection_no_recovery() {
let temp = tempfile::tempdir().expect("temp dir");
let coll =
Collection::create(PathBuf::from(temp.path()), 4, DistanceMetric::Cosine).expect("create");
let recovered =
recovery::recover_hnsw_gap(&coll.storage.vector_storage, &coll.storage.index, 4)
.expect("recovery");
assert_eq!(recovered, 0);
}
#[test]
fn test_crash_gap_detected_and_recovered() {
let temp = tempfile::tempdir().expect("temp dir");
let coll =
Collection::create(PathBuf::from(temp.path()), 4, DistanceMetric::Cosine).expect("create");
coll.upsert(make_points(3)).expect("upsert");
{
let mut vs = coll.storage.vector_storage.write();
vs.store(100, &[0.0, 0.0, 1.0, 0.0]).expect("store 100");
vs.store(101, &[0.0, 0.0, 0.0, 1.0]).expect("store 101");
}
assert_eq!(coll.storage.vector_storage.read().len(), 5);
assert_eq!(coll.storage.index.len(), 3);
let recovered =
recovery::recover_hnsw_gap(&coll.storage.vector_storage, &coll.storage.index, 4)
.expect("recovery");
assert_eq!(recovered, 2);
assert_eq!(coll.storage.index.len(), 5);
let results = coll.storage.index.search(&[0.0, 0.0, 1.0, 0.0], 1);
assert_eq!(results[0].id, 100, "recovered vector should be searchable");
}
#[test]
fn test_gap_recovery_on_collection_reopen() {
let temp = tempfile::tempdir().expect("temp dir");
{
let coll = Collection::create(PathBuf::from(temp.path()), 4, DistanceMetric::Cosine)
.expect("create");
coll.upsert(make_points(3)).expect("upsert");
coll.flush().expect("flush");
{
let mut vs = coll.storage.vector_storage.write();
vs.store(100, &[0.0, 0.0, 1.0, 0.0]).expect("store 100");
vs.store(101, &[0.0, 0.0, 0.0, 1.0]).expect("store 101");
vs.flush().expect("flush storage");
}
coll.flush().expect("flush hnsw");
}
let reopened = Collection::open(PathBuf::from(temp.path())).expect("reopen");
assert_eq!(
reopened.storage.index.len(),
5,
"HNSW should include 3 original + 2 recovered vectors"
);
let results = reopened.search(&[0.0, 0.0, 1.0, 0.0], 1).expect("search");
assert!(!results.is_empty(), "search should return results");
assert_eq!(
results[0].point.id, 100,
"recovered vector should be found by search"
);
}
#[test]
fn test_metadata_only_skips_recovery() {
let temp = tempfile::tempdir().expect("temp dir");
{
let _coll =
Collection::create_metadata_only(PathBuf::from(temp.path()), "meta").expect("create");
}
let reopened = Collection::open(PathBuf::from(temp.path())).expect("reopen");
assert_eq!(reopened.storage.config.read().dimension, 0);
}
#[cfg(feature = "persistence")]
#[test]
fn test_no_gap_after_flush_and_reopen() {
let temp = tempfile::tempdir().expect("temp dir");
{
let coll = Collection::create(PathBuf::from(temp.path()), 4, DistanceMetric::Cosine)
.expect("create");
coll.upsert(make_points(8)).expect("upsert");
coll.flush_full().expect("flush_full");
}
let reopened = Collection::open(PathBuf::from(temp.path())).expect("reopen");
let recovered =
recovery::recover_hnsw_gap(&reopened.storage.vector_storage, &reopened.storage.index, 4)
.expect("recover");
assert_eq!(recovered, 0, "no gap after clean flush+reopen");
assert_eq!(
reopened.storage.index.len(),
8,
"all vectors present in HNSW"
);
}
#[cfg(feature = "persistence")]
fn dispersed(id: u64) -> Vec<f32> {
let mut state = id.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
(0..8)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
#[allow(
clippy::cast_precision_loss,
reason = "the top 24 bits of the state are exact in an f32"
)]
let unit = (state >> 40) as f32 / (1u64 << 24) as f32;
unit
})
.collect()
}
#[cfg(feature = "persistence")]
fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).expect("test: create snapshot dir");
for entry in std::fs::read_dir(from).expect("test: read dir") {
let entry = entry.expect("test: dir entry");
let target = to.join(entry.file_name());
if entry.file_type().expect("test: file type").is_dir() {
copy_dir(&entry.path(), &target);
} else {
std::fs::copy(entry.path(), &target).expect("test: copy file");
}
}
}
#[test]
#[cfg(feature = "persistence")]
fn a_mapped_id_the_graph_never_linked_is_relinked_on_open() {
const POINTS: u64 = 300;
let temp = tempfile::tempdir().expect("temp dir");
let live = temp.path().join("live");
let coll = Collection::create_with_async_builder(
live.clone(),
8,
DistanceMetric::Euclidean,
crate::collection::streaming::AsyncIndexBuilderConfig {
merge_threshold: 10_000,
segment_count: Some(2),
},
)
.expect("create");
let points: Vec<Point> = (0..POINTS)
.map(|id| Point::without_payload(id, dispersed(id)))
.collect();
assert_eq!(
coll.upsert_bulk(&points).expect("upsert_bulk"),
points.len()
);
coll.storage
.index
.save(&coll.storage.path)
.expect("save with the builder still holding every point");
let premise = temp.path().join("premise");
copy_dir(&live, &premise);
let snapshot = temp.path().join("snapshot");
copy_dir(&live, &snapshot);
let saved = crate::index::HnswIndex::load(&premise, 8, DistanceMetric::Euclidean)
.expect("load the saved index");
let unlinked_before = saved
.inner
.read()
.unlinked_nodes(saved.mappings.iter().map(|(_, idx)| idx));
assert_eq!(
unlinked_before.len(),
usize::try_from(POINTS - 1).expect("test: fits a usize"),
"the save must leave every mapped id but the entry point unlinked"
);
let first_slot = saved.mappings.get_idx(0).expect("id 0 is mapped");
assert!(
!unlinked_before.contains(&first_slot),
"the one exempt node must be id 0's slot, the entry point"
);
drop(saved);
let reopened = Collection::open(snapshot).expect("open the snapshot");
let index = &reopened.storage.index;
let unlinked = index
.inner
.read()
.unlinked_nodes(index.mappings.iter().map(|(_, idx)| idx));
assert!(
unlinked.is_empty(),
"{} mapped ids are still unlinked after open",
unlinked.len()
);
for id in [0, POINTS / 2, POINTS - 1] {
let hits = reopened
.search_with_ef(
&dispersed(id),
1,
usize::try_from(4 * POINTS).expect("test: fits a usize"),
)
.expect("search");
assert_eq!(
hits.first().map(|hit| hit.point.id),
Some(id),
"id {id} is mapped but graph search cannot reach it"
);
}
}