use std::collections::HashMap;
use nodedb_types::DatabaseId;
use tracing::info;
use super::manifest::read_sparse_vector_manifest_at;
use super::paths::{parse_sparse_vector_key, sparse_vector_ckpt_dir, sparse_vector_ckpt_gen_dir};
use crate::data::executor::checkpoint_decode_error::CheckpointDecodeError;
use crate::data::executor::core_loop::CoreLoop;
use crate::engine::vector::sparse::SparseInvertedIndex;
use crate::types::{Lsn, TenantId};
type DecodedSparseVectorGeneration =
HashMap<(DatabaseId, TenantId, String, String), SparseInvertedIndex>;
impl CoreLoop {
pub fn load_sparse_vector_checkpoints(&mut self) -> crate::Result<()> {
let ckpt_dir = sparse_vector_ckpt_dir(&self.data_dir, self.core_id);
if !ckpt_dir.exists() {
return Ok(());
}
let Some(manifest) = read_sparse_vector_manifest_at(&ckpt_dir, self.core_id)? else {
return Ok(());
};
let gen_dir = sparse_vector_ckpt_gen_dir(&ckpt_dir, manifest.generation);
let decoded = self.decode_sparse_vector_generation(&gen_dir)?;
let indexes = decoded.len();
let mut docs = 0usize;
for (key, index) in decoded {
docs += index.doc_count();
self.sparse_vector_indexes.insert(key, index);
}
self.floors.sparse_vector_durable_lsn = Lsn::new(manifest.durable_through_lsn);
info!(
core = self.core_id,
generation = manifest.generation,
indexes,
docs,
durable_through_lsn = manifest.durable_through_lsn,
"sparse vector checkpoint restored"
);
Ok(())
}
fn decode_sparse_vector_generation(
&self,
gen_dir: &std::path::Path,
) -> Result<DecodedSparseVectorGeneration, CheckpointDecodeError> {
let entries =
std::fs::read_dir(gen_dir).map_err(|source| CheckpointDecodeError::ScanDir {
dir: gen_dir.to_path_buf(),
source,
})?;
let mut decoded = DecodedSparseVectorGeneration::new();
for entry in entries {
let entry = entry.map_err(|source| CheckpointDecodeError::DirEntry { source })?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ckpt") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let key = parse_sparse_vector_key(stem).ok_or_else(|| {
CheckpointDecodeError::UnparseableFilename {
stem: stem.to_string(),
}
})?;
let bytes = nodedb_wal::segment::read_checkpoint_framed(&path).map_err(|source| {
CheckpointDecodeError::ReadFile {
path: path.clone(),
source,
}
})?;
let index = SparseInvertedIndex::from_checkpoint(&bytes)
.ok_or_else(|| CheckpointDecodeError::UndecodableIndex { path: path.clone() })?;
decoded.insert(key, index);
}
Ok(decoded)
}
}
#[cfg(test)]
mod tests {
use super::super::format::{SPARSE_VECTOR_CKPT_FORMAT_VERSION, SparseVectorCheckpointManifest};
use super::super::paths::{SPARSE_VECTOR_CKPT_MANIFEST, sparse_vector_checkpoint_stem};
use super::*;
use nodedb_types::SparseVector;
fn index_with(docs: &[(&str, &[(u32, f32)])]) -> SparseInvertedIndex {
let mut index = SparseInvertedIndex::new();
for (doc_id, entries) in docs {
let vector = SparseVector::from_entries(entries.to_vec()).expect("finite weights");
index.insert(doc_id, &vector);
}
index
}
#[test]
fn index_roundtrips_through_the_checkpoint_file() {
let index = index_with(&[
("doc-a", &[(1, 0.5), (7, 0.25)]),
("doc-b", &[(7, 1.0), (9, 0.125)]),
]);
assert_eq!(index.doc_count(), 2);
let tmp = tempfile::tempdir().expect("tempdir");
let stem = sparse_vector_checkpoint_stem(0, 7, "docs", "emb");
let path = tmp.path().join(format!("{stem}.ckpt"));
let tmp_path = tmp.path().join(format!("{stem}.ckpt.tmp"));
let bytes = index.checkpoint_to_bytes().expect("encode");
nodedb_wal::segment::write_checkpoint_framed(&tmp_path, &path, &bytes).expect("write");
let read_back = nodedb_wal::segment::read_checkpoint_framed(&path).expect("read");
let restored = SparseInvertedIndex::from_checkpoint(&read_back).expect("decode");
assert_eq!(
restored.doc_count(),
index.doc_count(),
"every document must survive the round-trip"
);
assert_eq!(
restored.dim_count(),
index.dim_count(),
"every indexed dimension must survive the round-trip"
);
assert_eq!(
restored.total_postings(),
index.total_postings(),
"no posting may be dropped by the round-trip"
);
}
#[test]
fn emptied_index_roundtrips_as_empty() {
let mut index = index_with(&[("doc-a", &[(1, 0.5)])]);
index.delete("doc-a");
assert!(index.is_empty(), "the only document was deleted");
let bytes = index.checkpoint_to_bytes().expect("encode");
let restored =
SparseInvertedIndex::from_checkpoint(&bytes).expect("an empty index must still decode");
assert!(
restored.is_empty(),
"an emptied index must restore empty, never resurrect its documents"
);
}
#[test]
fn manifest_roundtrips_generation_and_lsn() {
let written = SparseVectorCheckpointManifest {
format_version: SPARSE_VECTOR_CKPT_FORMAT_VERSION,
generation: 4,
durable_through_lsn: 8_128,
};
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(SPARSE_VECTOR_CKPT_MANIFEST);
let tmp_path = tmp.path().join("m.tmp");
let bytes = zerompk::to_msgpack_vec(&written).expect("encode");
nodedb_wal::segment::write_checkpoint_framed(&tmp_path, &path, &bytes).expect("write");
let decoded = read_sparse_vector_manifest_at(tmp.path(), 0)
.expect("manifest must read")
.expect("manifest file exists, so this must be Some");
assert_eq!(
decoded.durable_through_lsn, 8_128,
"the manifest must report exactly the LSN it was written with"
);
assert_eq!(decoded.generation, 4);
}
#[test]
fn unknown_manifest_version_is_rejected() {
let written = SparseVectorCheckpointManifest {
format_version: SPARSE_VECTOR_CKPT_FORMAT_VERSION + 1,
generation: 1,
durable_through_lsn: 5,
};
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(SPARSE_VECTOR_CKPT_MANIFEST);
let tmp_path = tmp.path().join("m.tmp");
let bytes = zerompk::to_msgpack_vec(&written).expect("encode");
nodedb_wal::segment::write_checkpoint_framed(&tmp_path, &path, &bytes).expect("write");
read_sparse_vector_manifest_at(tmp.path(), 0)
.expect_err("a manifest this build cannot read must fail the load, not gate nothing");
}
#[test]
fn absent_manifest_reads_as_none() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
read_sparse_vector_manifest_at(tmp.path(), 0)
.expect("an absent manifest must not error")
.is_none()
);
}
#[test]
fn corrupt_manifest_fails_the_load() {
let tmp = tempfile::tempdir().expect("tempdir");
let core = open_core_at(tmp.path());
let ckpt_dir = sparse_vector_ckpt_dir(&core.data_dir, core.core_id);
std::fs::create_dir_all(&ckpt_dir).expect("create ckpt dir");
let manifest_path = ckpt_dir.join(SPARSE_VECTOR_CKPT_MANIFEST);
std::fs::write(&manifest_path, b"not a valid checkpoint frame")
.expect("write garbage manifest");
drop(core);
let mut restored = open_core_at(tmp.path());
restored
.load_sparse_vector_checkpoints()
.expect_err("a corrupt manifest must fail the load, not silently skip it");
}
fn open_core_at(dir: &std::path::Path) -> CoreLoop {
use std::sync::Arc;
use nodedb_bridge::buffer::RingBuffer;
use nodedb_types::OrdinalClock;
use crate::bridge::dispatch::{BridgeRequest, BridgeResponse};
let hlc = Arc::new(OrdinalClock::new());
let (req_tx, req_rx) = RingBuffer::channel::<BridgeRequest>(64);
let (resp_tx, _resp_rx) = RingBuffer::channel::<BridgeResponse>(64);
drop(req_tx); CoreLoop::open(0, req_rx, resp_tx, dir, hlc).expect("CoreLoop::open")
}
#[test]
fn flush_reports_its_lsn_and_a_restart_restores_every_index() {
let dir = tempfile::tempdir().expect("tempdir");
let mut before = open_core_at(dir.path());
before.sparse_vector_indexes.insert(
(
DatabaseId::new(0),
TenantId::new(7),
"docs".into(),
"emb".into(),
),
index_with(&[("doc-a", &[(1, 0.5)]), ("doc-b", &[(2, 0.25)])]),
);
before.sparse_vector_indexes.insert(
(
DatabaseId::new(0),
TenantId::new(7),
"my_docs".into(),
"title_emb".into(),
),
index_with(&[("doc-c", &[(3, 1.0)])]),
);
before.advance_watermark(Lsn::new(1_234));
let reported = before
.checkpoint_sparse_vector_indexes()
.expect("flush to a writable dir must succeed");
assert_eq!(
reported,
Lsn::new(1_234),
"the flush must report exactly the LSN it made durable — the manager \
deletes WAL segments below whatever this returns"
);
drop(before);
let mut after = open_core_at(dir.path());
assert!(
after.sparse_vector_indexes.is_empty(),
"a fresh core holds no indexes, or this test proves nothing"
);
after
.load_sparse_vector_checkpoints()
.expect("checkpoint load must succeed");
assert_eq!(
after.sparse_vector_indexes.len(),
2,
"every index in the published generation must restore"
);
let restored = after
.sparse_vector_indexes
.get(&(
DatabaseId::new(0),
TenantId::new(7),
"my_docs".into(),
"title_emb".into(),
))
.expect("an index must restore under the exact key it was written with");
assert_eq!(restored.doc_count(), 1);
assert_eq!(
after.floors.sparse_vector_durable_lsn,
Lsn::new(1_234),
"the restored durable LSN is what a failed flush clamps to; losing it \
would pin truncation at zero for the rest of the process"
);
}
}