use async_trait::async_trait;
use std::any::Any;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use laurus::lexical::LexicalIndexConfig;
use laurus::storage::memory::{MemoryStorage, MemoryStorageConfig};
use laurus::storage::{FileMetadata, LoadingMode, Storage, StorageInput, StorageOutput};
use laurus::vector::Vector;
use laurus::vector::core::distance::DistanceMetric;
use laurus::vector::core::field::HnswOption;
use laurus::vector::store::config::VectorFieldConfig;
use laurus::vector::store::request::{
QueryVector, VectorScoreMode, VectorSearchParams, VectorSearchRequest,
};
use laurus::vector::{FieldOption, VectorIndexConfig, VectorSearchQuery};
use laurus::{DataValue, Document};
use laurus::{EmbedInput, EmbedInputType, Embedder};
use laurus::{LaurusError, Result};
const DIM: usize = 16;
const STEP: f32 = 0.01;
const HNSW_FILE: &str = "vector_index.hnsw";
const DELMAP_FILE: &str = "vector_index.delmap";
#[derive(Debug)]
struct MockEmbedder {
dimension: usize,
}
#[async_trait]
impl Embedder for MockEmbedder {
async fn embed(&self, input: &EmbedInput<'_>) -> Result<Vector> {
match input {
EmbedInput::Text(_) => Ok(Vector::new(vec![0.0; self.dimension])),
_ => Err(LaurusError::invalid_argument("text only")),
}
}
fn supported_input_types(&self) -> Vec<EmbedInputType> {
vec![EmbedInputType::Text]
}
fn name(&self) -> &str {
"mock"
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Debug)]
struct CountingStorage {
inner: MemoryStorage,
opens: std::sync::Mutex<HashMap<String, usize>>,
creates: std::sync::Mutex<HashMap<String, usize>>,
fail_create_matching: std::sync::Mutex<Option<String>>,
}
impl CountingStorage {
fn new() -> Self {
Self {
inner: MemoryStorage::new(MemoryStorageConfig::default()),
opens: std::sync::Mutex::new(HashMap::new()),
creates: std::sync::Mutex::new(HashMap::new()),
fail_create_matching: std::sync::Mutex::new(None),
}
}
fn open_count(&self, name: &str) -> usize {
self.opens.lock().unwrap().get(name).copied().unwrap_or(0)
}
fn create_count_matching(&self, pat: &str) -> usize {
self.creates
.lock()
.unwrap()
.iter()
.filter(|(name, _)| name.contains(pat))
.map(|(_, n)| n)
.sum()
}
fn set_fail_create_matching(&self, pat: Option<&str>) {
*self.fail_create_matching.lock().unwrap() = pat.map(String::from);
}
}
impl Storage for CountingStorage {
fn loading_mode(&self) -> LoadingMode {
self.inner.loading_mode()
}
fn open_input(&self, name: &str) -> Result<Box<dyn StorageInput>> {
*self
.opens
.lock()
.unwrap()
.entry(name.to_string())
.or_insert(0) += 1;
self.inner.open_input(name)
}
fn create_output(&self, name: &str) -> Result<Box<dyn StorageOutput>> {
if let Some(pat) = self.fail_create_matching.lock().unwrap().as_deref()
&& name.contains(pat)
{
return Err(LaurusError::storage(format!(
"injected create_output failure for '{name}'"
)));
}
*self
.creates
.lock()
.unwrap()
.entry(name.to_string())
.or_insert(0) += 1;
self.inner.create_output(name)
}
fn create_output_append(&self, name: &str) -> Result<Box<dyn StorageOutput>> {
self.inner.create_output_append(name)
}
fn file_exists(&self, name: &str) -> bool {
self.inner.file_exists(name)
}
fn delete_file(&self, name: &str) -> Result<()> {
self.inner.delete_file(name)
}
fn list_files(&self) -> Result<Vec<String>> {
self.inner.list_files()
}
fn file_size(&self, name: &str) -> Result<u64> {
self.inner.file_size(name)
}
fn metadata(&self, name: &str) -> Result<FileMetadata> {
self.inner.metadata(name)
}
fn rename_file(&self, old_name: &str, new_name: &str) -> Result<()> {
self.inner.rename_file(old_name, new_name)
}
fn create_temp_output(&self, prefix: &str) -> Result<(String, Box<dyn StorageOutput>)> {
self.inner.create_temp_output(prefix)
}
fn sync(&self) -> Result<()> {
self.inner.sync()
}
fn close(&mut self) -> Result<()> {
self.inner.close()
}
}
fn doc_vec(i: u64) -> Vec<f32> {
let theta = i as f32 * STEP;
let mut v = vec![0.0; DIM];
v[0] = theta.cos();
v[1] = theta.sin();
v
}
fn vec_doc(i: u64) -> Document {
Document::builder()
.add_field("vec", DataValue::Vector(doc_vec(i)))
.build()
}
fn hnsw() -> FieldOption {
FieldOption::Hnsw(HnswOption {
dimension: DIM,
distance: DistanceMetric::Cosine,
m: 16,
ef_construction: 100,
default_ef_search: Some(400),
base_weight: 1.0,
quantizer: Default::default(),
rerank_storage: None,
embedder: None,
pq_codebook_path: None,
})
}
fn make_config(auto_compaction: bool, threshold: f64) -> VectorIndexConfig {
let mut field_configs = HashMap::new();
field_configs.insert(
"vec".to_string(),
VectorFieldConfig {
vector: Some(hnsw()),
lexical: None,
},
);
VectorIndexConfig {
fields: field_configs,
embedder: Arc::new(MockEmbedder { dimension: DIM }),
default_fields: vec!["vec".to_string()],
metadata: HashMap::new(),
deletion_config: laurus::DeletionConfig {
auto_compaction,
compaction_threshold: threshold,
..Default::default()
},
shard_id: 0,
metadata_config: LexicalIndexConfig::default(),
}
}
fn request(limit: usize) -> VectorSearchRequest {
let mut query = vec![0.0; DIM];
query[0] = 1.0;
VectorSearchRequest {
query: VectorSearchQuery::Vectors(vec![QueryVector {
vector: Vector::new(query),
weight: 1.0,
fields: Some(vec!["vec".into()]),
}]),
params: VectorSearchParams {
limit,
score_mode: VectorScoreMode::WeightedSum,
fields: None,
allowed_ids: None,
..Default::default()
},
}
}
fn hit_ids(store: &laurus::vector::VectorStore, limit: usize) -> HashSet<u64> {
store
.search(request(limit))
.unwrap()
.hits
.iter()
.map(|h| h.doc_id)
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn upsert_after_commit_does_not_reload_hnsw() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage, make_config(false, 0.5)).unwrap();
for id in 0..10u64 {
store
.upsert_document_by_internal_id(id, vec_doc(id))
.await
.unwrap();
}
store.commit().await.unwrap();
let opens_after_commit = counting.open_count(HNSW_FILE);
store
.upsert_document_by_internal_id(10, vec_doc(10))
.await
.unwrap();
assert_eq!(
counting.open_count(HNSW_FILE),
opens_after_commit,
"the retained writer must serve the post-commit upsert without \
re-opening the .hnsw (pre-#864: full reload per commit cycle)"
);
store.commit().await.unwrap();
assert_eq!(
counting.open_count(HNSW_FILE),
opens_after_commit,
"the second commit writes from the retained writer; no reload either"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn retained_writer_commit_cycles_preserve_all_vectors() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage.clone(), make_config(false, 0.5)).unwrap();
for cycle in 0..3u64 {
for i in 0..10u64 {
let id = cycle * 10 + i;
store
.upsert_document_by_internal_id(id, vec_doc(id))
.await
.unwrap();
}
store.commit().await.unwrap();
}
let expected: HashSet<u64> = (0..30).collect();
assert_eq!(
store.stats().unwrap().document_count,
30,
"all 30 records across 3 retained-writer commit cycles must persist \
(no loss, no duplication)"
);
assert_eq!(
hit_ids(&store, 30),
expected,
"search must return exactly the 30 ingested ids",
);
drop(store);
let reopened = laurus::vector::VectorStore::new(storage, make_config(false, 0.5)).unwrap();
assert_eq!(
reopened.stats().unwrap().document_count,
30,
"a cold reopen must see the same 30 records"
);
assert_eq!(
hit_ids(&reopened, 30),
expected,
"post-reopen search must return exactly the 30 ingested ids",
);
}
#[tokio::test(flavor = "multi_thread")]
async fn auto_compaction_invalidates_retained_writer_no_resurrection() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage.clone(), make_config(true, 0.3)).unwrap();
for id in 0..100u64 {
store
.upsert_document_by_internal_id(id, vec_doc(id))
.await
.unwrap();
}
store.commit().await.unwrap();
let deleted: HashSet<u64> = (0..40).collect();
for &id in &deleted {
store.delete_document_by_internal_id(id).await.unwrap();
}
store.commit().await.unwrap();
assert!(
!storage.file_exists(DELMAP_FILE),
"precondition: compaction must have run and removed the .delmap"
);
store
.upsert_document_by_internal_id(100, vec_doc(100))
.await
.unwrap();
store.commit().await.unwrap();
assert_eq!(
store.stats().unwrap().document_count,
61,
"exactly the 60 survivors + the new doc may exist on disk — more \
means the stale retained writer resurrected compacted-away records"
);
let survivors: HashSet<u64> = (40..=100).collect();
assert_eq!(
hit_ids(&store, 100),
survivors,
"search must return exactly the 60 survivors + the new doc, and no \
compacted-away record",
);
}
#[tokio::test(flavor = "multi_thread")]
async fn store_optimize_invalidates_retained_writer() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage.clone(), make_config(false, 0.9)).unwrap();
for id in 0..100u64 {
store
.upsert_document_by_internal_id(id, vec_doc(id))
.await
.unwrap();
}
store.commit().await.unwrap();
let deleted: HashSet<u64> = (0..40).collect();
for &id in &deleted {
store.delete_document_by_internal_id(id).await.unwrap();
}
store.commit().await.unwrap();
store.optimize().await.unwrap();
assert!(
!storage.file_exists(DELMAP_FILE),
"precondition: optimize must have reclaimed and removed the .delmap"
);
store
.upsert_document_by_internal_id(100, vec_doc(100))
.await
.unwrap();
store.commit().await.unwrap();
assert_eq!(
store.stats().unwrap().document_count,
61,
"exactly the 60 survivors + the new doc may exist on disk — more \
means the stale retained writer resurrected optimized-away records"
);
let survivors: HashSet<u64> = (40..=100).collect();
assert_eq!(
hit_ids(&store, 100),
survivors,
"search must return exactly the 60 survivors + the new doc, and no \
optimized-away record",
);
}
#[tokio::test(flavor = "multi_thread")]
async fn noop_commit_does_not_rewrite_hnsw() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage, make_config(false, 0.5)).unwrap();
for id in 0..10u64 {
store
.upsert_document_by_internal_id(id, vec_doc(id))
.await
.unwrap();
}
store.commit().await.unwrap();
let writes_after_commit = counting.create_count_matching(".hnsw");
assert!(
writes_after_commit > 0,
"the first commit must write the index"
);
store.commit().await.unwrap();
store.commit().await.unwrap();
assert_eq!(
counting.create_count_matching(".hnsw"),
writes_after_commit,
"no-change commits must not rewrite the .hnsw from the retained writer"
);
store
.upsert_document_by_internal_id(10, vec_doc(10))
.await
.unwrap();
store.commit().await.unwrap();
assert!(
counting.create_count_matching(".hnsw") > writes_after_commit,
"a commit with pending changes must write the index again"
);
assert_eq!(store.stats().unwrap().document_count, 11);
}
#[tokio::test(flavor = "multi_thread")]
async fn optimize_flushes_writer_emptied_by_deletions() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage, make_config(false, 0.9)).unwrap();
store
.upsert_document_by_internal_id(0, vec_doc(0))
.await
.unwrap();
store.commit().await.unwrap();
assert_eq!(store.stats().unwrap().document_count, 1);
let empty_doc = Document::builder()
.add_field("note", DataValue::Int64(42))
.build();
store
.upsert_document_by_internal_id(0, empty_doc)
.await
.unwrap();
store.optimize().await.unwrap();
assert_eq!(
store.stats().unwrap().document_count,
0,
"optimize must flush the emptied writer's uncommitted deletion \
instead of dropping it and resurrecting the stale vector"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_commit_retains_writer_for_retry() {
let counting = Arc::new(CountingStorage::new());
let storage: Arc<dyn Storage> = counting.clone();
let store = laurus::vector::VectorStore::new(storage, make_config(false, 0.5)).unwrap();
for id in 0..5u64 {
store
.upsert_document_by_internal_id(id, vec_doc(id))
.await
.unwrap();
}
store.commit().await.unwrap();
store
.upsert_document_by_internal_id(5, vec_doc(5))
.await
.unwrap();
counting.set_fail_create_matching(Some(".hnsw"));
store
.commit()
.await
.expect_err("the injected create_output failure must fail the commit");
counting.set_fail_create_matching(None);
store
.upsert_document_by_internal_id(6, vec_doc(6))
.await
.unwrap();
store.commit().await.unwrap();
let ids = hit_ids(&store, 10);
assert!(
ids.contains(&5) && ids.contains(&6),
"the retained writer must carry the failed doc through the retry: {ids:?}"
);
assert_eq!(
store.stats().unwrap().document_count,
7,
"docs 0-4 plus the retried doc 5 and the new doc 6"
);
}