use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use laurus::storage::Storage;
use laurus::storage::memory::{MemoryStorage, MemoryStorageConfig};
use laurus::vector::DistanceMetric;
use laurus::vector::core::field::HnswOption;
use laurus::vector::index::VectorIndex;
use laurus::vector::index::config::{HnswIndexConfig, VectorIndexTypeConfig};
use laurus::vector::index::multi_field::MultiFieldVectorIndex;
use laurus::{
DataValue, Document, EmbedInput, EmbedInputType, Embedder, Engine, FieldOption, Result, Schema,
};
#[derive(Debug)]
struct MockEmbedder;
#[async_trait]
impl Embedder for MockEmbedder {
async fn embed(&self, _input: &EmbedInput<'_>) -> Result<laurus::vector::Vector> {
Err(laurus::LaurusError::invalid_argument(
"embedding not used by this test",
))
}
fn supported_input_types(&self) -> Vec<EmbedInputType> {
vec![EmbedInputType::Text]
}
fn name(&self) -> &str {
"mock"
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn storage() -> Arc<dyn Storage> {
Arc::new(MemoryStorage::new(MemoryStorageConfig::default()))
}
fn hnsw_config(dimension: usize) -> VectorIndexTypeConfig {
VectorIndexTypeConfig::HNSW(HnswIndexConfig {
dimension,
distance_metric: DistanceMetric::Cosine,
normalize_vectors: true,
..Default::default()
})
}
#[test]
fn last_wal_seq_is_the_minimum_across_all_fields() {
let mut fields = BTreeMap::new();
fields.insert("a".to_string(), hnsw_config(3));
fields.insert("b".to_string(), hnsw_config(3));
let index =
MultiFieldVectorIndex::open_or_create(storage(), &fields, Arc::new(MockEmbedder)).unwrap();
index.set_last_wal_seq(100).unwrap();
index.persist_deletions().unwrap();
assert_eq!(
index.last_wal_seq(),
100,
"both fields start in sync at 100"
);
index.add_field("c", hnsw_config(3)).unwrap();
assert_eq!(
index.last_wal_seq(),
0,
"the minimum must reflect the just-added field's still-unpublished checkpoint"
);
index.persist_deletions().unwrap();
assert_eq!(
index.last_wal_seq(),
100,
"once every field's checkpoint is published, the minimum is the common value"
);
}
#[test]
fn removing_the_lagging_field_lifts_the_aggregate_to_the_remaining_fields_minimum() {
let mut fields = BTreeMap::new();
fields.insert("a".to_string(), hnsw_config(3));
fields.insert("b".to_string(), hnsw_config(3));
let index =
MultiFieldVectorIndex::open_or_create(storage(), &fields, Arc::new(MockEmbedder)).unwrap();
index.set_last_wal_seq(100).unwrap();
index.persist_deletions().unwrap();
index.add_field("c", hnsw_config(3)).unwrap();
assert_eq!(index.last_wal_seq(), 0);
index.remove_field("c").unwrap();
assert_eq!(
index.last_wal_seq(),
100,
"removing the lagging field must lift the aggregate back to the \
remaining fields' minimum, not leave it stuck at the removed \
field's value"
);
}
fn two_field_schema() -> Schema {
Schema::builder()
.add_field(
"title_vec",
FieldOption::Hnsw(HnswOption {
dimension: 3,
distance: DistanceMetric::Cosine,
..HnswOption::default()
}),
)
.add_field(
"body_vec",
FieldOption::Hnsw(HnswOption {
dimension: 3,
distance: DistanceMetric::Cosine,
..HnswOption::default()
}),
)
.build()
}
fn doc_with_both_fields(i: usize) -> Document {
let t = i as f32 * 0.01;
Document::builder()
.add_field("title_vec", DataValue::Vector(vec![t.cos(), t.sin(), 0.0]))
.add_field("body_vec", DataValue::Vector(vec![0.0, t.cos(), t.sin()]))
.build()
}
#[tokio::test(flavor = "multi_thread")]
async fn engine_recovery_replays_both_fields_after_uncommitted_crash() -> Result<()> {
let storage: Arc<dyn Storage> = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let schema = two_field_schema();
{
let engine = Engine::new(storage.clone(), schema.clone()).await?;
for i in 0..20 {
engine
.put_document(&format!("doc{i}"), doc_with_both_fields(i))
.await?;
}
}
{
let engine = Engine::new(storage.clone(), schema.clone()).await?;
engine.commit().await?;
let stats = engine.stats()?;
assert_eq!(stats.document_count, 20);
let title_stats = stats
.vector_fields
.get("title_vec")
.expect("title_vec must be present in stats");
let body_stats = stats
.vector_fields
.get("body_vec")
.expect("body_vec must be present in stats");
assert_eq!(
title_stats.vector_count, 20,
"title_vec must recover all 20 vectors, not be shadowed by body_vec"
);
assert_eq!(
body_stats.vector_count, 20,
"body_vec must recover all 20 vectors, not be shadowed by title_vec"
);
}
Ok(())
}