Skip to main content

gonzalo_vector/
shard.rs

1//! Shard assignment and the on-blob format for a durable vector index.
2//!
3//! Vectors are bucketed by a hash of their key so a write touches one shard
4//! rather than the whole index, and a shard's bytes are deterministic so an
5//! unchanged shard content-addresses to the blob already stored.
6
7use gonzalo_core::{ContentHash, CoreError, RecordKey, Result};
8use std::num::NonZeroU16;
9
10/// Default number of shards for a new index. At ~100k chunks of 384 f32s this
11/// is ~600 KB per shard: 256 reads to open, one ~600 KB rewrite per upsert.
12pub const DEFAULT_SHARDS: NonZeroU16 = match NonZeroU16::new(256) {
13    Some(n) => n,
14    None => panic!("256 is nonzero"),
15};
16
17const MAGIC: &[u8; 4] = b"GZVS";
18const VERSION: u8 = 1;
19
20/// Which shard `key` belongs to.
21///
22/// Uses blake3 via [`ContentHash`] rather than [`std::hash::DefaultHasher`],
23/// whose output is explicitly not stable across releases — a drift there would
24/// silently strand every vector in every existing index.
25pub fn shard_of(key: &RecordKey, shards: NonZeroU16) -> u16 {
26    let s = format!("{}/{}/{}", key.namespace, key.collection, key.id);
27    let hex = ContentHash::of(s.as_bytes()).0;
28    let bits = u16::from_str_radix(&hex[..4], 16).expect("blake3 hex is 64 hex digits");
29    bits % shards.get()
30}
31
32/// Encode one shard. Entries are sorted by key, so identical contents always
33/// produce identical bytes.
34///
35/// `pub(crate)`, not `pub`: this freezes the `GZVS` v1 on-disk format as a
36/// public contract, and nothing outside this crate has a reason to encode a
37/// shard directly — a caller works through [`crate::VectorIndex`].
38pub(crate) fn encode_shard(dim: usize, entries: &[(RecordKey, Vec<f32>)]) -> Vec<u8> {
39    let mut sorted: Vec<&(RecordKey, Vec<f32>)> = entries.iter().collect();
40    sorted.sort_by(|a, b| a.0.cmp(&b.0));
41
42    let mut out = Vec::new();
43    out.extend_from_slice(MAGIC);
44    out.push(VERSION);
45    out.extend_from_slice(&(dim as u32).to_le_bytes());
46    out.extend_from_slice(&(sorted.len() as u32).to_le_bytes());
47    for (key, vector) in sorted {
48        put_str16(&mut out, &key.namespace);
49        put_str16(&mut out, &key.collection);
50        out.extend_from_slice(&(key.id.len() as u32).to_le_bytes());
51        out.extend_from_slice(key.id.as_bytes());
52        for f in vector {
53            out.extend_from_slice(&f.to_le_bytes());
54        }
55    }
56    out
57}
58
59/// Decode one shard, returning its dimension and entries.
60///
61/// Rejects corrupted or truncated blobs as `CoreError::Backend`, including those
62/// with unbounded length fields that could cause allocation failures.
63///
64/// `pub(crate)` for the same reason as [`encode_shard`]: the format is not a
65/// public contract, and every caller of this crate goes through
66/// [`crate::VectorIndex`] instead.
67#[allow(clippy::type_complexity)]
68pub(crate) fn decode_shard(bytes: &[u8]) -> Result<(usize, Vec<(RecordKey, Vec<f32>)>)> {
69    let mut r = Reader { bytes, at: 0 };
70    if r.take(4)? != MAGIC {
71        return Err(CoreError::Backend("vector shard: bad magic".into()));
72    }
73    let version = r.take(1)?[0];
74    if version != VERSION {
75        return Err(CoreError::Backend(format!(
76            "vector shard: unsupported version {version}, expected {VERSION}"
77        )));
78    }
79    let dim = r.u32()? as usize;
80    let count = r.u32()? as usize;
81
82    // Cap the allocation by what the remaining input could actually contain.
83    // Each entry needs at least 8 bytes (two u16-length fields for namespace and
84    // collection) + 4 bytes (id length) + dim * 4 bytes (floats).
85    let remaining = bytes.len().saturating_sub(r.at);
86    let min_entry_size = 8_usize.saturating_add(dim.saturating_mul(4));
87    let max_count = remaining.checked_div(min_entry_size).unwrap_or(usize::MAX);
88    if count > max_count {
89        return Err(CoreError::Backend(
90            "vector shard: entry count exceeds remaining data".into(),
91        ));
92    }
93
94    let mut entries = Vec::new();
95    for _ in 0..count {
96        let namespace = r.str16()?;
97        let collection = r.str16()?;
98        let id_len = r.u32()? as usize;
99        let id = String::from_utf8(r.take(id_len)?.to_vec())
100            .map_err(|e| CoreError::Backend(format!("vector shard: bad utf8 in id: {e}")))?;
101        let mut vector = Vec::new();
102        for _ in 0..dim {
103            let b: [u8; 4] = r.take(4)?.try_into().expect("took exactly 4 bytes");
104            vector.push(f32::from_le_bytes(b));
105        }
106        entries.push((RecordKey::new(namespace, collection, id), vector));
107    }
108    Ok((dim, entries))
109}
110
111fn put_str16(out: &mut Vec<u8>, s: &str) {
112    out.extend_from_slice(&(s.len() as u16).to_le_bytes());
113    out.extend_from_slice(s.as_bytes());
114}
115
116struct Reader<'a> {
117    bytes: &'a [u8],
118    at: usize,
119}
120
121impl<'a> Reader<'a> {
122    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
123        let end = self.at.checked_add(n).ok_or_else(overflow)?;
124        let slice = self
125            .bytes
126            .get(self.at..end)
127            .ok_or_else(|| CoreError::Backend("vector shard: truncated".into()))?;
128        self.at = end;
129        Ok(slice)
130    }
131
132    fn u32(&mut self) -> Result<u32> {
133        let b: [u8; 4] = self.take(4)?.try_into().expect("took exactly 4 bytes");
134        Ok(u32::from_le_bytes(b))
135    }
136
137    fn str16(&mut self) -> Result<String> {
138        let b: [u8; 2] = self.take(2)?.try_into().expect("took exactly 2 bytes");
139        let len = u16::from_le_bytes(b) as usize;
140        String::from_utf8(self.take(len)?.to_vec())
141            .map_err(|e| CoreError::Backend(format!("vector shard: bad utf8: {e}")))
142    }
143}
144
145fn overflow() -> CoreError {
146    CoreError::Backend("vector shard: length overflow".into())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn key(id: &str) -> RecordKey {
154        RecordKey::new("ns", "coll", id)
155    }
156
157    #[test]
158    fn shard_assignment_is_stable_across_calls() {
159        let k = key("a");
160        let shards = NonZeroU16::new(256).unwrap();
161        assert_eq!(shard_of(&k, shards), shard_of(&k, shards));
162    }
163
164    // The assignment must not drift between releases, or every existing index
165    // silently loses track of where its vectors live. Pinning one literal makes
166    // an accidental change to the hashing fail loudly here.
167    #[test]
168    fn shard_assignment_is_pinned_to_a_known_value() {
169        assert_eq!(shard_of(&key("a"), NonZeroU16::new(256).unwrap()), 121);
170    }
171
172    #[test]
173    fn shard_assignment_respects_the_shard_count() {
174        let shards = NonZeroU16::new(4).unwrap();
175        for i in 0..100 {
176            assert!(shard_of(&key(&i.to_string()), shards) < 4);
177        }
178    }
179
180    #[test]
181    fn round_trips_entries_and_dim() {
182        let entries = vec![
183            (key("a"), vec![1.0, 2.0, 3.0]),
184            (key("b"), vec![-1.5, 0.0, 2.5]),
185        ];
186        let bytes = encode_shard(3, &entries);
187        let (dim, decoded) = decode_shard(&bytes).unwrap();
188        assert_eq!(dim, 3);
189        assert_eq!(decoded, entries);
190    }
191
192    #[test]
193    fn round_trips_an_empty_shard() {
194        let bytes = encode_shard(4, &[]);
195        let (dim, decoded) = decode_shard(&bytes).unwrap();
196        assert_eq!(dim, 4);
197        assert!(decoded.is_empty());
198    }
199
200    // Identical contents must produce identical bytes, so an untouched shard
201    // hashes to the blob already stored and costs nothing to "rewrite".
202    #[test]
203    fn encoding_is_deterministic_regardless_of_input_order() {
204        let a = vec![(key("b"), vec![1.0]), (key("a"), vec![2.0])];
205        let b = vec![(key("a"), vec![2.0]), (key("b"), vec![1.0])];
206        assert_eq!(encode_shard(1, &a), encode_shard(1, &b));
207    }
208
209    #[test]
210    fn decode_rejects_a_bad_magic() {
211        let mut bytes = encode_shard(1, &[(key("a"), vec![1.0])]);
212        bytes[0] = b'X';
213        assert!(matches!(decode_shard(&bytes), Err(CoreError::Backend(_))));
214    }
215
216    #[test]
217    fn decode_rejects_an_unknown_version() {
218        let mut bytes = encode_shard(1, &[(key("a"), vec![1.0])]);
219        bytes[4] = 99;
220        assert!(matches!(decode_shard(&bytes), Err(CoreError::Backend(_))));
221    }
222
223    #[test]
224    fn decode_rejects_a_truncated_shard() {
225        let bytes = encode_shard(2, &[(key("a"), vec![1.0, 2.0])]);
226        let truncated = &bytes[..bytes.len() - 3];
227        assert!(matches!(
228            decode_shard(truncated),
229            Err(CoreError::Backend(_))
230        ));
231    }
232
233    #[test]
234    fn decode_rejects_a_huge_entry_count() {
235        // A shard with count field set to u32::MAX but only a few actual bytes.
236        // Should reject this without attempting a multi-gigabyte allocation.
237        let mut bytes = vec![];
238        bytes.extend_from_slice(b"GZVS");
239        bytes.push(1); // version
240        bytes.extend_from_slice(&(1u32).to_le_bytes()); // dim = 1
241        bytes.extend_from_slice(&(u32::MAX).to_le_bytes()); // count = u32::MAX
242        assert!(matches!(decode_shard(&bytes), Err(CoreError::Backend(_))));
243    }
244
245    #[test]
246    fn decode_rejects_a_huge_dimension() {
247        // A shard with dim field set to u32::MAX but only a few actual bytes.
248        // Should reject this without attempting a multi-gigabyte allocation.
249        let mut bytes = vec![];
250        bytes.extend_from_slice(b"GZVS");
251        bytes.push(1); // version
252        bytes.extend_from_slice(&(u32::MAX).to_le_bytes()); // dim = u32::MAX
253        bytes.extend_from_slice(&(1u32).to_le_bytes()); // count = 1
254        assert!(matches!(decode_shard(&bytes), Err(CoreError::Backend(_))));
255    }
256}