use nodedb_types::Namespace;
use crate::engine::vector::VectorState;
use crate::error::LiteError;
use crate::nodedb::lock_ext::LockExt;
use crate::storage::engine::StorageEngine;
pub(super) fn sidecar_storage_key(index_key: &str) -> String {
format!("sidecar:{index_key}")
}
pub(crate) async fn persist_sidecar<S: StorageEngine>(
vector_state: &VectorState<S>,
index_key: &str,
) -> Result<(), LiteError> {
let bytes = {
let sidecars = vector_state.codec_sidecars.lock_or_recover();
match sidecars.get(index_key) {
None => return Ok(()),
Some(sidecar) => match sidecar.to_bytes() {
Ok(b) => b,
Err(e) => {
tracing::warn!(
index_key,
error = %e,
"sidecar serialize failed; skipping persist (will rebuild on restart)"
);
return Ok(());
}
},
}
};
let key = sidecar_storage_key(index_key);
if let Err(e) = vector_state
.storage
.put(Namespace::Vector, key.as_bytes(), &bytes)
.await
{
tracing::warn!(
index_key,
error = %e,
"sidecar storage write failed; in-memory sidecar remains valid"
);
}
Ok(())
}
pub(crate) async fn remove_sidecar<S: StorageEngine>(
vector_state: &VectorState<S>,
index_key: &str,
) -> Result<(), LiteError> {
vector_state
.codec_sidecars
.lock_or_recover()
.remove(index_key);
let key = sidecar_storage_key(index_key);
vector_state
.storage
.delete(Namespace::Vector, key.as_bytes())
.await
}
pub(crate) async fn try_restore_sidecar<S: StorageEngine>(
vector_state: &VectorState<S>,
index_key: &str,
) -> Result<bool, LiteError> {
{
let sidecars = vector_state.codec_sidecars.lock_or_recover();
if sidecars.contains_key(index_key) {
return Ok(true);
}
}
let key = sidecar_storage_key(index_key);
let bytes = vector_state
.storage
.get(Namespace::Vector, key.as_bytes())
.await?;
let bytes = match bytes {
None => return Ok(false),
Some(b) => b,
};
let sidecar =
nodedb_vector::rerank::CodecSidecar::from_bytes(&bytes, vector_state.memory.clone())
.map_err(|e| LiteError::Storage {
detail: format!("sidecar restore for '{index_key}': {e}"),
})?;
vector_state
.codec_sidecars
.lock_or_recover()
.insert(index_key.to_string(), sidecar);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap as StdHashMap;
use std::sync::{Arc, Mutex as StdMutex};
use async_trait::async_trait;
use nodedb_types::Namespace;
use nodedb_vector::HnswIndex;
use nodedb_vector::rerank::CodecName;
use crate::engine::vector::sidecar::install::install_sidecar_for_index;
use crate::engine::vector::state::VectorState;
use crate::error::LiteError;
use crate::storage::engine::{KvPair, StorageEngine, WriteOp};
struct RealMemStore {
data: StdMutex<StdHashMap<Vec<u8>, Vec<u8>>>,
}
impl RealMemStore {
fn new() -> Self {
Self {
data: StdMutex::new(StdHashMap::new()),
}
}
fn write_raw(&self, key: &[u8], value: Vec<u8>) {
self.data.lock().unwrap().insert(key.to_vec(), value);
}
}
#[async_trait]
impl StorageEngine for RealMemStore {
async fn get(&self, _ns: Namespace, key: &[u8]) -> Result<Option<Vec<u8>>, LiteError> {
Ok(self.data.lock().unwrap().get(key).cloned())
}
async fn put(&self, _ns: Namespace, key: &[u8], value: &[u8]) -> Result<(), LiteError> {
self.data
.lock()
.unwrap()
.insert(key.to_vec(), value.to_vec());
Ok(())
}
async fn delete(&self, _ns: Namespace, key: &[u8]) -> Result<(), LiteError> {
self.data.lock().unwrap().remove(key);
Ok(())
}
async fn scan_prefix(
&self,
_ns: Namespace,
_prefix: &[u8],
) -> Result<Vec<KvPair>, LiteError> {
Ok(Vec::new())
}
async fn batch_write(&self, _ops: &[WriteOp]) -> Result<(), LiteError> {
Ok(())
}
async fn count(&self, _ns: Namespace) -> Result<u64, LiteError> {
Ok(0)
}
async fn scan_range(
&self,
_ns: Namespace,
_start: &[u8],
_limit: usize,
) -> Result<Vec<KvPair>, LiteError> {
Ok(Vec::new())
}
async fn scan_range_bounded(
&self,
_ns: Namespace,
_start: Option<&[u8]>,
_end: Option<&[u8]>,
_limit: Option<usize>,
) -> Result<Vec<KvPair>, LiteError> {
Ok(Vec::new())
}
}
fn make_state() -> VectorState<RealMemStore> {
let governor = crate::query::engine::test_governor();
let memory =
crate::query::engine::test_scoped_memory(&governor, nodedb_mem::EngineId::Vector);
VectorState::new(Arc::new(RealMemStore::new()), 50, memory)
}
fn populate_index(state: &VectorState<RealMemStore>, index_key: &str, dim: usize, n: usize) {
let mut indices = state.hnsw_indices.lock_or_recover();
let index = indices
.entry(index_key.to_string())
.or_insert_with(|| HnswIndex::new(dim, Default::default()));
for i in 0..n {
let v: Vec<f32> = (0..dim).map(|j| (i * dim + j) as f32 * 0.01).collect();
index.insert(v).expect("insert ok");
}
}
#[tokio::test]
async fn persist_then_restore_sq8() {
let state = make_state();
let key = "col_persist_sq8";
const DIM: usize = 16;
const N: usize = 3;
populate_index(&state, key, DIM, N);
install_sidecar_for_index(&state, key, CodecName::Sq8).expect("install ok");
assert_eq!(
state
.codec_sidecars
.lock_or_recover()
.get(key)
.unwrap()
.len(),
N
);
persist_sidecar(&state, key).await.expect("persist ok");
state.codec_sidecars.lock_or_recover().remove(key);
assert!(
!state.codec_sidecars.lock_or_recover().contains_key(key),
"sidecar must be gone before restore"
);
let restored = try_restore_sidecar(&state, key).await.expect("restore ok");
assert!(restored, "try_restore_sidecar must return Ok(true)");
let sidecars = state.codec_sidecars.lock_or_recover();
let sidecar = sidecars.get(key).expect("sidecar present after restore");
assert_eq!(
sidecar.len(),
N,
"restored sidecar must hold all {N} encoded entries"
);
assert_eq!(sidecar.codec_name(), CodecName::Sq8);
}
#[tokio::test]
async fn try_restore_returns_false_when_no_persisted_bytes() {
let state = make_state();
let result = try_restore_sidecar(&state, "nonexistent_col")
.await
.expect("should not error when no bytes stored");
assert!(!result, "expected Ok(false) when nothing is persisted");
}
#[tokio::test]
async fn try_restore_returns_err_on_corrupt_bytes() {
let state = make_state();
let key = "col_corrupt";
let storage_key = sidecar_storage_key(key);
state
.storage
.write_raw(storage_key.as_bytes(), b"GARBAGE_NOT_A_SIDECAR".to_vec());
let result = try_restore_sidecar(&state, key).await;
assert!(
result.is_err(),
"corrupt bytes must return Err, got: {result:?}"
);
}
#[tokio::test]
async fn persist_no_sidecar_is_noop() {
let state = make_state();
let result = persist_sidecar(&state, "no_sidecar_key").await;
assert!(result.is_ok(), "persist with no sidecar must return Ok(())");
}
}