Skip to main content

nodedb_lite/engine/vector/
pagedb_backing.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `PagedbBacking`: low-allocation [`VectorSegmentBacking`] backed by
4//! a decrypted pagedb segment loaded into a heap-pinned byte buffer.
5//!
6//! The segment stores raw NDVS v2 bytes (header + vectors + padding +
7//! surrogates + footer) spread across 4 KiB encrypted pagedb data pages.
8//! On `open`, all pages are decrypted and concatenated into a single
9//! `Box<[u8]>`.  Subsequent `get_vector` / `get_surrogate` calls are pure
10//! pointer arithmetic into that buffer — no I/O, no allocation per lookup.
11//!
12//! # Format identity
13//!
14//! The byte layout inside the buffer is bit-identical to the NDVS v2 format
15//! used by `nodedb_vector::mmap_segment`.  A segment written by `PagedbBacking`
16//! can be opened by `MmapVectorSegment::open` on Origin (and vice-versa), which
17//! is the cross-deployment compatibility contract.
18//!
19//! # Compile scope
20//!
21//! This module is only compiled on non-WASM targets.  WASM uses the legacy
22//! blob checkpoint path.
23
24use pagedb::SegmentReader;
25use pagedb::vfs::Vfs;
26
27use nodedb_vector::segment_backing::VectorSegmentBacking;
28
29use crate::error::LiteError;
30
31// ── NDVS v2 format constants (must match nodedb-vector's mmap_segment::format) ─
32
33const NDVS_MAGIC: [u8; 4] = *b"NDVS";
34const NDVS_FORMAT_VERSION: u16 = 1;
35const NDVS_HEADER_SIZE: usize = 32;
36const NDVS_FOOTER_SIZE: usize = 46;
37/// Bytes per F32 element.
38const F32_BYTES: usize = 4;
39/// Bytes per surrogate ID (u64).
40const SID_BYTES: usize = 8;
41
42/// 8-byte-aligned padding after the vector data block so surrogates are
43/// naturally aligned.  Mirrors `nodedb_vector::mmap_segment::format::vec_pad`.
44#[inline]
45const fn vec_pad(vec_bytes: usize) -> usize {
46    (8 - (vec_bytes % 8)) % 8
47}
48
49// ── PagedbBacking ─────────────────────────────────────────────────────────────
50
51/// [`VectorSegmentBacking`] backed by a decrypted pagedb segment.
52///
53/// The decrypted NDVS payload is held in a heap-pinned `Box<[u8]>`.  Vector
54/// and surrogate ID accesses are pointer arithmetic into this buffer —
55/// no I/O, no allocation on the hot path.
56///
57/// `Box<[u8]>` is `Send + Sync`; no unsafe impls are needed.
58pub struct PagedbBacking {
59    /// Decrypted NDVS bytes (header + vectors + pad + surrogates + footer).
60    data: Box<[u8]>,
61    dim: usize,
62    count: usize,
63    /// Byte offset of the vector data block inside `data`.
64    vec_offset: usize,
65    /// Byte offset of the surrogate ID block inside `data`.
66    sid_offset: usize,
67}
68
69impl PagedbBacking {
70    /// Reconstruct from a pagedb `SegmentReader` that was written by
71    /// [`crate::storage::vector_segment_ext::VectorSegmentExt::write_vector_segment`].
72    ///
73    /// All data pages are decrypted via `read_extent` and concatenated into a
74    /// single `Box<[u8]>`.  The NDVS header is then parsed to extract `dim`
75    /// and `count`.
76    ///
77    /// Takes the reader by value so the resulting future is `Send` regardless
78    /// of whether `V::File` is `Sync`.
79    ///
80    /// Returns `LiteError` on any I/O, decryption, or format error.
81    pub async fn open<V: Vfs + Clone>(reader: SegmentReader<V>) -> Result<Self, LiteError> {
82        let meta_page_count = reader.meta().page_count;
83        // Layout: page 0 = structural header, pages 1..D = NDVS data,
84        //         pages D+1..E = v2 extent index, page E+1 = footer.
85        // D = page_count - 2 - index_pages.
86        let index_pages = u64::from(reader.index_page_count());
87        let data_page_count = meta_page_count
88            .checked_sub(2 + index_pages)
89            .ok_or_else(|| LiteError::Storage {
90                detail: format!(
91                    "vector segment page_count={meta_page_count} too small \
92                     (index_pages={index_pages})"
93                ),
94            })?;
95        if data_page_count == 0 {
96            return Err(LiteError::Storage {
97                detail: "vector segment has no data pages".to_owned(),
98            });
99        }
100        let count_u32 = u32::try_from(data_page_count).map_err(|_| LiteError::Storage {
101            detail: format!("vector segment has too many data pages: {data_page_count}"),
102        })?;
103
104        // Decrypt and collect all data pages.
105        let pages = reader
106            .read_range(1, count_u32)
107            .await
108            .map_err(|e| LiteError::Storage {
109                detail: format!("pagedb vector segment read_range failed: {e}"),
110            })?;
111
112        // Concatenate page bodies into a flat buffer.
113        let total: usize = pages.iter().map(|p| p.len()).sum();
114        let mut flat = Vec::with_capacity(total);
115        for page in pages {
116            flat.extend_from_slice(&page);
117        }
118
119        Self::from_bytes(flat.into_boxed_slice())
120    }
121
122    /// Parse the NDVS header from `data` and compute layout offsets.
123    pub(crate) fn from_bytes(data: Box<[u8]>) -> Result<Self, LiteError> {
124        if data.len() < NDVS_HEADER_SIZE + NDVS_FOOTER_SIZE {
125            return Err(LiteError::Storage {
126                detail: format!(
127                    "vector segment too small: {} bytes (min {})",
128                    data.len(),
129                    NDVS_HEADER_SIZE + NDVS_FOOTER_SIZE
130                ),
131            });
132        }
133
134        // Validate magic.
135        if data[0..4] != NDVS_MAGIC {
136            return Err(LiteError::Storage {
137                detail: "vector segment: bad NDVS magic".to_owned(),
138            });
139        }
140
141        // Validate format version.
142        let version = u16::from_le_bytes([data[4], data[5]]);
143        if version != NDVS_FORMAT_VERSION {
144            return Err(LiteError::Storage {
145                detail: format!(
146                    "vector segment: unsupported NDVS version {version} \
147                     (expected {NDVS_FORMAT_VERSION})"
148                ),
149            });
150        }
151
152        // dim at [8..12], count at [12..20].
153        let dim = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
154        let count = u64::from_le_bytes([
155            data[12], data[13], data[14], data[15], data[16], data[17], data[18], data[19],
156        ]) as usize;
157
158        let vec_bytes = dim
159            .checked_mul(count)
160            .and_then(|n| n.checked_mul(F32_BYTES))
161            .ok_or_else(|| LiteError::Storage {
162                detail: "vector segment: vector data size overflow".to_owned(),
163            })?;
164
165        let vec_offset = NDVS_HEADER_SIZE;
166        let sid_offset = NDVS_HEADER_SIZE + vec_bytes + vec_pad(vec_bytes);
167        let min_len = sid_offset
168            .checked_add(count.saturating_mul(SID_BYTES))
169            .and_then(|n| n.checked_add(NDVS_FOOTER_SIZE))
170            .ok_or_else(|| LiteError::Storage {
171                detail: "vector segment: layout size overflow".to_owned(),
172            })?;
173
174        if data.len() < min_len {
175            return Err(LiteError::Storage {
176                detail: format!(
177                    "vector segment too small for declared count={count} dim={dim}: \
178                     need {min_len} bytes, have {}",
179                    data.len()
180                ),
181            });
182        }
183
184        Ok(Self {
185            data,
186            dim,
187            count,
188            vec_offset,
189            sid_offset,
190        })
191    }
192
193    /// Number of vectors.
194    pub fn count(&self) -> usize {
195        self.count
196    }
197}
198
199impl VectorSegmentBacking for PagedbBacking {
200    fn len(&self) -> usize {
201        self.count
202    }
203
204    fn dim(&self) -> usize {
205        self.dim
206    }
207
208    fn get_vector(&self, id: u32) -> Option<&[f32]> {
209        let id = id as usize;
210        if id >= self.count {
211            return None;
212        }
213        let byte_start = self.vec_offset + id * self.dim * F32_BYTES;
214        let bytes = &self.data[byte_start..byte_start + self.dim * F32_BYTES];
215        // SAFETY: the NDVS writer serialised F32 values as `f32 → u8` bytes.
216        // The vector block starts at `NDVS_HEADER_SIZE` (32 bytes from the
217        // buffer start).  The buffer was allocated by `Vec::with_capacity` and
218        // then boxed — guaranteed at least 8-byte-aligned by the global allocator.
219        // Each vector starts at an offset that is a multiple of 4 bytes from a
220        // 4-byte-aligned base, so the pointer is 4-byte-aligned.
221        // The slice length equals `dim`; the data is immutable after construction.
222        Some(unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const f32, self.dim) })
223    }
224
225    fn get_surrogate(&self, id: u32) -> Option<u64> {
226        let id = id as usize;
227        if id >= self.count {
228            return None;
229        }
230        let offset = self.sid_offset + id * SID_BYTES;
231        let bytes = &self.data[offset..offset + SID_BYTES];
232        Some(u64::from_le_bytes(
233            bytes.try_into().expect("surrogate slice is always 8 bytes"),
234        ))
235    }
236
237    fn prefetch(&self, id: u32) {
238        let id = id as usize;
239        if id < self.count {
240            // Touch the first byte of the vector to warm the CPU cache.
241            let byte_start = self.vec_offset + id * self.dim * F32_BYTES;
242            let _ = self.data.get(byte_start);
243        }
244    }
245}
246
247// ── Segment write helpers (called by VectorSegmentExt impl) ──────────────────
248
249/// Serialise vectors and surrogate IDs to an in-memory NDVS v2 byte buffer.
250///
251/// The layout is bit-identical to `nodedb_vector::mmap_segment::writer::write_segment`
252/// so segments written here can be opened by `MmapVectorSegment::open` on
253/// Origin (format-bit-identity guarantee for cross-deployment compat).
254pub(crate) fn build_ndvs_bytes(
255    dim: usize,
256    vectors: &[Vec<f32>],
257    surrogate_ids: &[u64],
258) -> Result<Vec<u8>, LiteError> {
259    // HARD validation, not `debug_assert`. These invariants were previously
260    // checked only in debug builds, so a release build happily serialized a
261    // header declaring `count`/`dim` above a payload that did not contain them
262    // — producing a segment whose reopen fails with "too small for declared
263    // count". A malformed segment must fail the WRITE, loudly, not the read.
264    if !surrogate_ids.is_empty() && surrogate_ids.len() != vectors.len() {
265        return Err(LiteError::Storage {
266            detail: format!(
267                "NDVS build: {} surrogate ids for {} vectors (must match or be empty)",
268                surrogate_ids.len(),
269                vectors.len()
270            ),
271        });
272    }
273    if let Some((i, v)) = vectors.iter().enumerate().find(|(_, v)| v.len() != dim) {
274        return Err(LiteError::Storage {
275            detail: format!(
276                "NDVS build: vector {i} has {} dimensions, expected {dim}. An empty \
277                 vector here means the source index carries no vector data — e.g. a \
278                 graph-only checkpoint whose external backing was not consulted — and \
279                 serializing it would publish a segment that cannot be read back.",
280                v.len()
281            ),
282        });
283    }
284
285    let count = vectors.len();
286    let vec_bytes = dim
287        .checked_mul(count)
288        .and_then(|n| n.checked_mul(F32_BYTES))
289        .ok_or_else(|| LiteError::Storage {
290            detail: "NDVS build: vector data size overflow".to_owned(),
291        })?;
292    let pad = vec_pad(vec_bytes);
293    let sid_bytes = count
294        .checked_mul(SID_BYTES)
295        .ok_or_else(|| LiteError::Storage {
296            detail: "NDVS build: surrogate block size overflow".to_owned(),
297        })?;
298    let body_len = NDVS_HEADER_SIZE + vec_bytes + pad + sid_bytes;
299    let total_len = body_len + NDVS_FOOTER_SIZE;
300    let mut buf: Vec<u8> = Vec::with_capacity(total_len);
301
302    // Header (32 bytes).
303    buf.extend_from_slice(&NDVS_MAGIC);
304    buf.extend_from_slice(&NDVS_FORMAT_VERSION.to_le_bytes()); // [4..6] version
305    buf.extend_from_slice(&0u16.to_le_bytes()); // [6..8] flags
306    buf.extend_from_slice(&(dim as u32).to_le_bytes()); // [8..12] dim
307    buf.extend_from_slice(&(count as u64).to_le_bytes()); // [12..20] count
308    buf.push(0u8); // [20] dtype = F32
309    buf.push(0u8); // [21] codec = None
310    buf.extend_from_slice(&[0u8; 10]); // [22..32] reserved
311
312    debug_assert_eq!(buf.len(), NDVS_HEADER_SIZE);
313
314    // Vector data block.
315    for v in vectors {
316        // SAFETY: safe f32 → u8 cast; `u8` has alignment 1, so the cast is
317        // always valid.  We only read the bytes, never write through this ptr.
318        let bytes: &[u8] =
319            unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * F32_BYTES) };
320        buf.extend_from_slice(bytes);
321    }
322
323    // Alignment padding.
324    buf.extend_from_slice(&[0u8; 8][..pad]);
325
326    // Surrogate ID block.
327    for i in 0..count {
328        let sid = surrogate_ids.get(i).copied().unwrap_or(0);
329        buf.extend_from_slice(&sid.to_le_bytes());
330    }
331
332    debug_assert_eq!(buf.len(), body_len);
333
334    // Compute CRC32C over the body.
335    let checksum = crc32c::crc32c(&buf);
336
337    // Footer (46 bytes).
338    buf.extend_from_slice(&NDVS_FORMAT_VERSION.to_le_bytes()); // [0..2]
339    let mut created_by = [0u8; 32];
340    let ver = env!("CARGO_PKG_VERSION").as_bytes();
341    let copy_len = ver.len().min(31);
342    created_by[..copy_len].copy_from_slice(&ver[..copy_len]);
343    buf.extend_from_slice(&created_by); // [2..34]
344    buf.extend_from_slice(&checksum.to_le_bytes()); // [34..38]
345    buf.extend_from_slice(&(NDVS_FOOTER_SIZE as u32).to_le_bytes()); // [38..42]
346    buf.extend_from_slice(&NDVS_MAGIC); // [42..46]
347
348    debug_assert_eq!(buf.len(), total_len);
349
350    Ok(buf)
351}
352
353// ── Tests ─────────────────────────────────────────────────────────────────────
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use pagedb::options::{OpenOptions, RetainPolicy};
359    use pagedb::vfs::memory::MemVfs;
360    use pagedb::{Db, RealmId, SegmentKind};
361
362    fn test_open_options() -> OpenOptions {
363        OpenOptions::default().with_commit_history_retain(RetainPolicy::Disabled)
364    }
365
366    async fn open_test_db() -> Db<MemVfs> {
367        let vfs = MemVfs::new();
368        let kek = [0u8; 32];
369        let realm = RealmId::new([0u8; 16]);
370        Db::open(vfs, kek, 4096, realm, test_open_options())
371            .await
372            .expect("in-memory pagedb open")
373    }
374
375    fn test_vectors(dim: usize, n: usize) -> Vec<Vec<f32>> {
376        (0..n)
377            .map(|i| (0..dim).map(|j| (i * dim + j) as f32 * 0.1).collect())
378            .collect()
379    }
380
381    // Write NDVS bytes into a pagedb segment and link it.
382    async fn write_segment_to_db(
383        db: &Db<MemVfs>,
384        name: &str,
385        dim: usize,
386        vectors: &[Vec<f32>],
387        surrogates: &[u64],
388    ) {
389        let realm = RealmId::new([0u8; 16]);
390        let ndvs = build_ndvs_bytes(dim, vectors, surrogates).expect("build ok");
391
392        // Chunk NDVS bytes into page-body-sized pieces.
393        const PAGE_BODY_CAP: usize = 4096 - 40; // 4096 - ENVELOPE_OVERHEAD
394        let chunks: Vec<&[u8]> = ndvs.chunks(PAGE_BODY_CAP).collect();
395
396        let mut writer = db
397            .create_segment(realm, SegmentKind::Unspecified)
398            .await
399            .expect("create segment ok");
400        writer
401            .append_extent(&chunks)
402            .await
403            .expect("append_extent ok");
404        let meta = writer.seal().await.expect("seal ok");
405
406        let mut txn = db.begin_write().await.expect("begin_write ok");
407        txn.link_segment(name, &meta).await.expect("link ok");
408        txn.commit().await.expect("commit ok");
409    }
410
411    #[tokio::test]
412    async fn roundtrip_with_pagedb_segment() {
413        let db = open_test_db().await;
414        let dim = 4usize;
415        let vecs = test_vectors(dim, 5);
416        let surrogates: Vec<u64> = (10..15).collect();
417
418        write_segment_to_db(&db, "vec/hnsw/col1", dim, &vecs, &surrogates).await;
419
420        // Open via ReadTxn.
421        let txn = db.begin_read().await.expect("begin_read ok");
422        let reader = txn
423            .open_segment("vec/hnsw/col1")
424            .await
425            .expect("open_segment ok");
426        let backing = PagedbBacking::open(reader).await.expect("open backing ok");
427
428        assert_eq!(backing.len(), 5);
429        assert_eq!(backing.dim(), dim);
430        assert!(!backing.is_empty());
431
432        for i in 0..5usize {
433            let got = backing.get_vector(i as u32).expect("vector present");
434            assert_eq!(got, vecs[i].as_slice(), "vector {i} mismatch");
435            let sid = backing.get_surrogate(i as u32).expect("surrogate present");
436            assert_eq!(sid, surrogates[i], "surrogate {i} mismatch");
437        }
438    }
439
440    #[tokio::test]
441    async fn out_of_bounds_returns_none() {
442        let db = open_test_db().await;
443        let dim = 3usize;
444        let vecs = test_vectors(dim, 2);
445        let surrogates: Vec<u64> = vec![100, 200];
446
447        write_segment_to_db(&db, "vec/hnsw/oob", dim, &vecs, &surrogates).await;
448
449        let txn = db.begin_read().await.expect("begin_read ok");
450        let reader = txn
451            .open_segment("vec/hnsw/oob")
452            .await
453            .expect("open_segment ok");
454        let backing = PagedbBacking::open(reader).await.expect("open ok");
455
456        assert!(backing.get_vector(2).is_none(), "id=2 out of bounds");
457        assert!(
458            backing.get_surrogate(2).is_none(),
459            "surrogate id=2 out of bounds"
460        );
461        backing.prefetch(999); // must not panic
462    }
463
464    #[test]
465    fn is_send_sync() {
466        fn assert_send_sync<T: Send + Sync>() {}
467        assert_send_sync::<PagedbBacking>();
468    }
469
470    #[tokio::test]
471    async fn bit_identical_with_plain_ndvs() {
472        // Build NDVS bytes via our helper and verify format correctness.
473        let dim = 3usize;
474        let vectors: Vec<Vec<f32>> = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
475        let surrogates: Vec<u64> = vec![42, 99];
476
477        let our_bytes = build_ndvs_bytes(dim, &vectors, &surrogates).expect("build ok");
478
479        // Validate magic and version match the nodedb-vector constants.
480        assert_eq!(&our_bytes[0..4], b"NDVS", "magic mismatch");
481        assert_eq!(
482            u16::from_le_bytes([our_bytes[4], our_bytes[5]]),
483            1u16,
484            "version mismatch"
485        );
486
487        // Validate dim and count.
488        let got_dim =
489            u32::from_le_bytes([our_bytes[8], our_bytes[9], our_bytes[10], our_bytes[11]]);
490        let got_count = u64::from_le_bytes(our_bytes[12..20].try_into().unwrap());
491        assert_eq!(got_dim as usize, dim);
492        assert_eq!(got_count as usize, 2);
493
494        // Validate vector data block.
495        let vec_offset = NDVS_HEADER_SIZE;
496        let v0_f32: [f32; 3] = [1.0, 2.0, 3.0];
497        let expected_v0: &[u8] =
498            unsafe { std::slice::from_raw_parts(v0_f32.as_ptr() as *const u8, 12) };
499        assert_eq!(&our_bytes[vec_offset..vec_offset + 12], expected_v0);
500        let v1_f32: [f32; 3] = [4.0, 5.0, 6.0];
501        let expected_v1: &[u8] =
502            unsafe { std::slice::from_raw_parts(v1_f32.as_ptr() as *const u8, 12) };
503        assert_eq!(&our_bytes[vec_offset + 12..vec_offset + 24], expected_v1);
504
505        // Validate surrogate IDs (3 floats × 4 = 12 bytes; pad = 4 since 12 % 8 ≠ 0).
506        // vec_bytes = 2 × 3 × 4 = 24 bytes; 24 % 8 = 0 → pad = 0.
507        let sid_offset = vec_offset + 24;
508        let sid0 = u64::from_le_bytes(our_bytes[sid_offset..sid_offset + 8].try_into().unwrap());
509        let sid1 = u64::from_le_bytes(
510            our_bytes[sid_offset + 8..sid_offset + 16]
511                .try_into()
512                .unwrap(),
513        );
514        assert_eq!(sid0, 42u64);
515        assert_eq!(sid1, 99u64);
516
517        // Validate trailing NDVS magic in footer.
518        let footer_trailing_magic_offset = our_bytes.len() - 4;
519        assert_eq!(&our_bytes[footer_trailing_magic_offset..], b"NDVS");
520
521        // Round-trip through PagedbBacking via in-memory pagedb segment.
522        let db = open_test_db().await;
523        write_segment_to_db(&db, "vec/hnsw/bitcheck", dim, &vectors, &surrogates).await;
524        let txn = db.begin_read().await.expect("read txn");
525        let reader = txn
526            .open_segment("vec/hnsw/bitcheck")
527            .await
528            .expect("open seg");
529        let backing = PagedbBacking::open(reader).await.expect("open backing");
530
531        for (i, v) in vectors.iter().enumerate() {
532            assert_eq!(
533                backing.get_vector(i as u32).expect("vector"),
534                v.as_slice(),
535                "vector {i} roundtrip"
536            );
537        }
538        assert_eq!(backing.get_surrogate(0).expect("sid0"), 42);
539        assert_eq!(backing.get_surrogate(1).expect("sid1"), 99);
540    }
541}