use std::collections::HashMap;
use nodedb_types::Namespace;
use nodedb_types::error::{NodeDbError, NodeDbResult};
use crate::storage::engine::{StorageEngine, WriteOp};
use super::index::SparseInvertedIndex;
const INDEX_LIST_KEY: &[u8] = b"sparse:_indices";
type SerDocument = (String, Vec<(u32, f32)>);
fn documents_key(index_key: &str) -> String {
format!("sparse:{index_key}:docs")
}
pub(crate) fn serialize_sparse(
indices: &HashMap<String, SparseInvertedIndex>,
) -> NodeDbResult<Vec<WriteOp>> {
let mut ops: Vec<WriteOp> = Vec::with_capacity(indices.len() + 1);
let mut index_keys: Vec<String> = indices.keys().cloned().collect();
index_keys.sort();
let keys_bytes = zerompk::to_msgpack_vec(&index_keys)
.map_err(|e| NodeDbError::serialization("msgpack", e))?;
ops.push(WriteOp::Put {
ns: Namespace::Vector,
key: INDEX_LIST_KEY.to_vec(),
value: keys_bytes,
});
for (index_key, index) in indices {
let documents: Vec<SerDocument> = index.documents();
let bytes = zerompk::to_msgpack_vec(&documents)
.map_err(|e| NodeDbError::serialization("msgpack", e))?;
ops.push(WriteOp::Put {
ns: Namespace::Vector,
key: documents_key(index_key).into_bytes(),
value: bytes,
});
}
Ok(ops)
}
pub(crate) async fn write_serialized_sparse<S>(storage: &S, ops: Vec<WriteOp>) -> NodeDbResult<()>
where
S: StorageEngine,
{
if ops.is_empty() {
return Ok(());
}
storage
.batch_write(&ops)
.await
.map_err(|e| NodeDbError::storage(format!("sparse checkpoint batch_write: {e}")))?;
Ok(())
}
pub(crate) async fn restore_sparse<S>(
storage: &S,
) -> NodeDbResult<Option<HashMap<String, SparseInvertedIndex>>>
where
S: StorageEngine,
{
let Some(keys_bytes) = storage.get(Namespace::Vector, INDEX_LIST_KEY).await? else {
return Ok(None);
};
let Ok(index_keys) = zerompk::from_msgpack::<Vec<String>>(&keys_bytes) else {
tracing::warn!("sparse checkpoint: index list undecodable — starting fresh");
return Ok(None);
};
let mut indices: HashMap<String, SparseInvertedIndex> =
HashMap::with_capacity(index_keys.len());
for index_key in &index_keys {
let key = documents_key(index_key);
let Some(bytes) = storage.get(Namespace::Vector, key.as_bytes()).await? else {
indices.insert(index_key.clone(), SparseInvertedIndex::new());
continue;
};
match zerompk::from_msgpack::<Vec<SerDocument>>(&bytes) {
Ok(documents) => {
indices.insert(
index_key.clone(),
SparseInvertedIndex::from_documents(documents),
);
}
Err(e) => {
tracing::warn!(
index_key,
error = %e,
"sparse checkpoint: document blob undecodable — index restored empty"
);
indices.insert(index_key.clone(), SparseInvertedIndex::new());
}
}
}
tracing::debug!(index_count = indices.len(), "sparse checkpoint restored");
Ok(Some(indices))
}
#[cfg(test)]
mod tests {
use nodedb_types::SparseVector;
use super::*;
use crate::PagedbStorageMem;
fn sv(entries: &[(u32, f32)]) -> SparseVector {
SparseVector::from_entries(entries.to_vec()).expect("valid sparse vector")
}
#[tokio::test]
async fn round_trip_through_storage() {
let storage = PagedbStorageMem::open_in_memory()
.await
.expect("in-memory pagedb");
let mut index = SparseInvertedIndex::new();
index.insert("d1", &sv(&[(1, 0.5), (9, 0.25)]));
index.insert("d2", &sv(&[(9, 1.0)]));
let mut indices = HashMap::new();
indices.insert("docs:emb".to_string(), index);
let ops = serialize_sparse(&indices).expect("serialize");
write_serialized_sparse(&storage, ops).await.expect("write");
let restored = restore_sparse(&storage)
.await
.expect("restore")
.expect("checkpoint present");
let restored_index = restored.get("docs:emb").expect("index restored");
assert_eq!(restored_index.doc_count(), 2);
let hits = restored_index.search(&sv(&[(9, 1.0)]), 10);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].doc_id, "d2");
}
#[tokio::test]
async fn absent_checkpoint_reports_none() {
let storage = PagedbStorageMem::open_in_memory()
.await
.expect("in-memory pagedb");
let restored = restore_sparse(&storage).await.expect("restore");
assert!(restored.is_none());
}
}