Skip to main content

nodedb_vector/mmap_segment/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Writer for the NDVS v2 on-disk vector segment format.
4
5use std::path::Path;
6
7use super::format::{
8    DTYPE_F32, FOOTER_SIZE, FORMAT_VERSION, HEADER_SIZE, MAGIC, VectorSegmentCodec, vec_pad,
9};
10
11/// Write a v2 NDVS segment file as `name` inside `dir`.
12///
13/// `surrogate_ids[i]` is the u64 surrogate for `vectors[i]`. The slice may be
14/// empty, in which case all surrogate IDs are written as 0.
15///
16/// The segment is assembled in memory and published with a single
17/// `write → sync_data → rename → fsync_dir` sequence, so the final name only
18/// ever names a complete, footer-terminated segment. Writing into the final
19/// path directly had two crash windows: truncating it destroyed an existing
20/// segment before anything valid replaced it, and a crash between the body and
21/// footer syncs left a final-named file that no reader could validate.
22///
23/// # Errors
24///
25/// Returns `std::io::Error` on any I/O failure, arithmetic overflow, or a
26/// `name` that is not one plain path component.
27pub fn write_segment(
28    dir: &Path,
29    name: &str,
30    dim: usize,
31    vectors: &[&[f32]],
32    surrogate_ids: &[u64],
33) -> std::io::Result<()> {
34    debug_assert!(
35        surrogate_ids.is_empty() || surrogate_ids.len() == vectors.len(),
36        "surrogate_ids length must match vectors length or be empty"
37    );
38
39    std::fs::create_dir_all(dir)?;
40
41    let count = vectors.len() as u64;
42
43    let vec_bytes = dim
44        .checked_mul(vectors.len())
45        .and_then(|n| n.checked_mul(4))
46        .ok_or_else(|| {
47            std::io::Error::new(std::io::ErrorKind::InvalidData, "vector data size overflow")
48        })?;
49    let surrogate_bytes = vectors.len().checked_mul(8).ok_or_else(|| {
50        std::io::Error::new(
51            std::io::ErrorKind::InvalidData,
52            "surrogate block size overflow",
53        )
54    })?;
55    let pad_bytes = vec_pad(vec_bytes);
56    let body_len = HEADER_SIZE + vec_bytes + pad_bytes + surrogate_bytes;
57
58    let mut buf = Vec::with_capacity(body_len + FOOTER_SIZE);
59
60    // Header — 32 bytes.
61    buf.extend_from_slice(&MAGIC);
62    buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
63    buf.extend_from_slice(&0u16.to_le_bytes()); // flags
64    buf.extend_from_slice(&(dim as u32).to_le_bytes());
65    buf.extend_from_slice(&count.to_le_bytes());
66    buf.push(DTYPE_F32);
67    buf.push(VectorSegmentCodec::None as u8);
68    buf.extend_from_slice(&[0u8; 10]); // reserved (header total 32, 8-byte aligned)
69
70    // Vector data block — D × N × 4 bytes, row-major, no framing.
71    for v in vectors {
72        debug_assert_eq!(v.len(), dim, "vector dimension mismatch during write");
73        // Safe: `f32` has no padding or invalid bit patterns, so its backing
74        // storage is always a valid byte slice of the same lifetime.
75        let bytes: &[u8] =
76            unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 4) };
77        buf.extend_from_slice(bytes);
78    }
79
80    // Pad to 8-byte alignment so the surrogate ID block is naturally aligned.
81    if pad_bytes > 0 {
82        buf.extend_from_slice(&[0u8; 8][..pad_bytes]);
83    }
84
85    // Surrogate ID block — N × 8 bytes.
86    for i in 0..vectors.len() {
87        let sid: u64 = surrogate_ids.get(i).copied().unwrap_or(0);
88        buf.extend_from_slice(&sid.to_le_bytes());
89    }
90
91    if buf.len() != body_len {
92        return Err(std::io::Error::other(format!(
93            "unexpected segment body size: {} vs {body_len}",
94            buf.len()
95        )));
96    }
97
98    // CRC32C over the body (header + vector block + pad + surrogate block).
99    let checksum = crc32c::crc32c(&buf);
100
101    let mut created_by = [0u8; 32];
102    let ver = env!("CARGO_PKG_VERSION").as_bytes();
103    let copy_len = ver.len().min(31);
104    created_by[..copy_len].copy_from_slice(&ver[..copy_len]);
105
106    // Footer — 46 bytes.
107    buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes()); // format_version [0..2]
108    buf.extend_from_slice(&created_by); // created_by                       [2..34]
109    buf.extend_from_slice(&checksum.to_le_bytes()); // checksum             [34..38]
110    buf.extend_from_slice(&(FOOTER_SIZE as u32).to_le_bytes()); // size     [38..42]
111    buf.extend_from_slice(&MAGIC); // trailing magic                        [42..46]
112
113    nodedb_wal::segment::atomic_write_fsync(dir, name, &buf).map_err(|e| {
114        std::io::Error::other(format!("publish segment {}: {e}", dir.join(name).display()))
115    })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn read_footer_checksum(bytes: &[u8]) -> u32 {
123        let start = bytes.len() - FOOTER_SIZE;
124        u32::from_le_bytes([
125            bytes[start + 34],
126            bytes[start + 35],
127            bytes[start + 36],
128            bytes[start + 37],
129        ])
130    }
131
132    /// The published file is always complete: body + footer, checksum covering
133    /// the body, trailing magic intact, and no tmp file left beside it.
134    #[test]
135    fn publishes_a_complete_footer_terminated_segment() {
136        let dir = tempfile::tempdir().expect("tempdir");
137        let path = dir.path().join("seg.ndvs");
138        let v = [1.0f32, 2.0, 3.0];
139
140        write_segment(dir.path(), "seg.ndvs", 3, &[&v], &[7]).expect("write");
141
142        let bytes = std::fs::read(&path).expect("read");
143        assert!(bytes.len() > FOOTER_SIZE);
144        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC, "trailing magic");
145        assert_eq!(
146            read_footer_checksum(&bytes),
147            crc32c::crc32c(&bytes[..bytes.len() - FOOTER_SIZE]),
148            "footer checksum must cover exactly the body"
149        );
150
151        let leftovers: Vec<String> = std::fs::read_dir(dir.path())
152            .expect("read_dir")
153            .flatten()
154            .filter_map(|e| e.file_name().to_str().map(str::to_string))
155            .collect();
156        assert_eq!(
157            leftovers,
158            vec!["seg.ndvs".to_string()],
159            "the staging file must be renamed away, not left behind"
160        );
161    }
162
163    /// Rewriting a segment replaces it via rename — the destination is never
164    /// truncated in place, so it never exists in a bodyless or footerless state.
165    #[test]
166    fn rewrite_replaces_the_previous_segment_wholesale() {
167        let dir = tempfile::tempdir().expect("tempdir");
168        let path = dir.path().join("seg.ndvs");
169
170        let a = [1.0f32, 2.0];
171        write_segment(dir.path(), "seg.ndvs", 2, &[&a, &a, &a], &[1, 2, 3]).expect("first write");
172        let first_len = std::fs::metadata(&path).expect("stat").len();
173
174        let b = [9.0f32, 8.0];
175        write_segment(dir.path(), "seg.ndvs", 2, &[&b], &[4]).expect("second write");
176        let bytes = std::fs::read(&path).expect("read");
177
178        assert_ne!(bytes.len() as u64, first_len, "smaller segment replaced it");
179        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC);
180        assert_eq!(
181            read_footer_checksum(&bytes),
182            crc32c::crc32c(&bytes[..bytes.len() - FOOTER_SIZE])
183        );
184        assert_eq!(
185            std::fs::read_dir(dir.path()).expect("read_dir").count(),
186            1,
187            "no staging file survives a rewrite"
188        );
189    }
190
191    /// An empty segment is still a valid, footer-terminated file.
192    #[test]
193    fn empty_segment_is_valid() {
194        let dir = tempfile::tempdir().expect("tempdir");
195        let path = dir.path().join("empty.ndvs");
196
197        write_segment(dir.path(), "empty.ndvs", 4, &[], &[]).expect("write");
198
199        let bytes = std::fs::read(&path).expect("read");
200        assert_eq!(bytes.len(), HEADER_SIZE + FOOTER_SIZE);
201        assert_eq!(
202            read_footer_checksum(&bytes),
203            crc32c::crc32c(&bytes[..HEADER_SIZE])
204        );
205    }
206}