use nodedb_client::NodeDb;
use nodedb_lite::NodeDbLite;
use nodedb_types::document::Document;
use nodedb_types::value::Value;
const COLLECTION: &str = "docs";
fn doc(id: &str) -> Document {
let mut d = Document::new(COLLECTION);
d.id = id.to_string();
d.set("body", Value::String("hello".into()));
d
}
#[tokio::test]
async fn vector_is_durable_without_an_explicit_flush() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let vector: Vec<f32> = (0..8).map(|i| i as f32).collect();
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
db.document_put_with_vector(COLLECTION, doc("a"), COLLECTION, "a", &vector)
.await
.unwrap();
db.shutdown().await;
drop(db);
}
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
let hits = db
.vector_search(COLLECTION, &vector, 5, None, None)
.await
.unwrap();
assert!(
hits.iter().any(|h| h.id == "a"),
"a vector written with its document must survive reopen WITHOUT a flush; \
got {hits:?}. If this fails the vector is once again reachable only \
through the in-memory HNSW."
);
}
#[tokio::test]
async fn vector_survives_reopen_after_flush() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let vector: Vec<f32> = (0..8).map(|i| (8 - i) as f32).collect();
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
db.document_put_with_vector(COLLECTION, doc("b"), COLLECTION, "b", &vector)
.await
.unwrap();
db.flush().await.unwrap();
db.shutdown().await;
drop(db);
}
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
let hits = db
.vector_search(COLLECTION, &vector, 5, None, None)
.await
.unwrap();
assert!(
hits.iter().any(|h| h.id == "b"),
"a flushed vector must survive reopen; got {hits:?}"
);
}
#[tokio::test]
async fn reopened_search_returns_document_ids() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
for (i, id) in ["x", "y", "z"].iter().enumerate() {
let v: Vec<f32> = (0..8).map(|k| (k + i) as f32).collect();
db.document_put_with_vector(COLLECTION, doc(id), COLLECTION, id, &v)
.await
.unwrap();
}
db.shutdown().await;
drop(db);
}
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
let probe: Vec<f32> = (0..8).map(|k| k as f32).collect();
let hits = db
.vector_search(COLLECTION, &probe, 3, None, None)
.await
.unwrap();
assert!(!hits.is_empty(), "expected hits after reopen");
for h in &hits {
assert!(
["x", "y", "z"].contains(&h.id.as_str()),
"hit id {:?} is not a document id — the id map is stale or missing",
h.id
);
}
}
#[tokio::test]
async fn second_flush_after_graph_only_restore_preserves_vectors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let vectors: Vec<Vec<f32>> = (0..3)
.map(|i| (0..8).map(|k| (k + i * 8) as f32).collect())
.collect();
let ids = ["p", "q", "r"];
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
for (id, v) in ids.iter().zip(&vectors) {
db.document_put_with_vector(COLLECTION, doc(id), COLLECTION, id, v)
.await
.unwrap();
}
db.flush().await.unwrap();
db.shutdown().await;
drop(db);
}
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
db.flush().await.unwrap();
db.shutdown().await;
drop(db);
}
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
for (id, v) in ids.iter().zip(&vectors) {
let hits = db
.vector_search(COLLECTION, v, 5, None, None)
.await
.unwrap();
assert!(
hits.iter().any(|h| h.id == *id),
"{id:?} was lost by the second flush — the segment was rewritten from \
the restored index's empty vector slots instead of the durable rows; \
got {hits:?}"
);
}
}
#[tokio::test]
async fn eviction_after_graph_only_restore_preserves_vectors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let vector: Vec<f32> = (0..8).map(|i| (i * 3) as f32).collect();
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
db.document_put_with_vector(COLLECTION, doc("e"), COLLECTION, "e", &vector)
.await
.unwrap();
db.flush().await.unwrap();
db.shutdown().await;
drop(db);
}
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
db.vector_search(COLLECTION, &vector, 1, None, None)
.await
.unwrap();
db.evict_collections(1).await.unwrap();
db.shutdown().await;
drop(db);
}
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
let hits = db
.vector_search(COLLECTION, &vector, 5, None, None)
.await
.unwrap();
assert!(
hits.iter().any(|h| h.id == "e"),
"eviction rewrote the segment from the restored index's empty vector \
slots instead of the durable rows; got {hits:?}"
);
}
#[tokio::test]
async fn deleted_vector_does_not_resurrect_on_rebuild() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let vector: Vec<f32> = (0..8).map(|i| i as f32).collect();
{
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
db.document_put_with_vector(COLLECTION, doc("gone"), COLLECTION, "gone", &vector)
.await
.unwrap();
db.vector_delete(COLLECTION, "gone").await.unwrap();
db.shutdown().await;
drop(db);
}
let db = NodeDbLite::open_at_path(path, nodedb_lite::Encryption::Plaintext)
.await
.unwrap();
let hits = db
.vector_search(COLLECTION, &vector, 5, None, None)
.await
.unwrap();
assert!(
!hits.iter().any(|h| h.id == "gone"),
"a deleted vector must not be resurrected by the rebuild; got {hits:?}"
);
}
#[tokio::test]
async fn earlier_row_layout_is_rewritten_on_open() {
use nodedb_lite::{Encryption, PagedbStorageDefault, StorageEngine};
use nodedb_types::Namespace;
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let vector: [f32; 2] = [0.6, 0.8];
let bytes: Vec<u8> = vector.iter().flat_map(|v| v.to_le_bytes()).collect();
{
let storage = PagedbStorageDefault::open(path, Encryption::Plaintext)
.await
.unwrap();
storage
.put(Namespace::Vector, b"v:legacy:a", &bytes)
.await
.unwrap();
let db = NodeDbLite::open(storage).await.unwrap();
let hits = db
.vector_search("legacy", &vector, 5, None, None)
.await
.unwrap();
assert_eq!(hits.first().map(|h| h.id.as_str()), Some("a"));
db.shutdown().await;
}
let storage = PagedbStorageDefault::open(path, Encryption::Plaintext)
.await
.unwrap();
assert!(
storage
.scan_prefix(Namespace::Vector, b"v:")
.await
.unwrap()
.is_empty(),
"no row keeps the earlier layout"
);
let db = NodeDbLite::open(storage).await.unwrap();
let hits = db
.vector_search("legacy", &vector, 5, None, None)
.await
.unwrap();
assert_eq!(hits.first().map(|h| h.id.as_str()), Some("a"));
}