Skip to main content

kevy_store/
packed_row.rs

1//! `PackedRow` — a declared table's row as one allocation.
2//!
3//! A hash on a declared prefix has a known column order, so the row does not
4//! need a table to answer "which field is `dept`" and does not need to carry
5//! the field names at all. What it needs is the values and where each one
6//! starts.
7//!
8//! The measured defect this removes is not that the general representation
9//! is large but that it is **flat**: a hash of three fields and a hash of
10//! twelve cost the same 1,700 bytes of RSS, because `KevyMap` rounds to
11//! `MIN_CAP = 16` and a promoted hash asks for `with_capacity(1)`, so every
12//! hash from one to fourteen fields allocates the same 16-slot table. Here
13//! every term scales with the row's actual shape instead.
14//!
15//! ```text
16//! [ncol u16][present bitmap ⌈ncol/8⌉][end_1 u16] … [end_n u16][values …]
17//! ```
18//!
19//! Ends rather than starts: column `i` occupies `end[i-1] .. end[i]`, with
20//! `end[-1]` the first byte after the header, so a length is one subtraction
21//! and no separate length array exists. A column that is absent has its bit
22//! clear; a column that is present and empty has the bit set and a zero-width
23//! span — the two are distinct, which `HEXISTS` needs and an offset-equality
24//! convention could not express.
25//!
26//! `u16` ends cap a packed row at 64 KiB of values. Callers build through
27//! [`PackedRow::build`], which returns `None` past that, and the caller keeps
28//! the general representation — a size class, not a failure.
29
30/// The largest total value payload a packed row can address.
31pub const PACKED_MAX: usize = u16::MAX as usize;
32
33/// The column names of one declared table, shared by every row in it.
34///
35/// A packed row has to be able to name its columns — `HGETALL`, the AOF
36/// rewrite and the snapshot writer all need field names, and none of them
37/// can reach the table catalog, which lives above the store. Carrying the
38/// names per row would reintroduce exactly the cost this type removes, so
39/// they live here: one allocation per TABLE, cloned into each row as a
40/// pointer.
41#[cfg(not(feature = "std"))]
42use crate::nostd_prelude::*;
43#[cfg(not(feature = "std"))]
44use alloc::vec;
45
46/// The column names a table's packed rows share, held once behind an
47/// `Arc` rather than per row — the whole point of the packed form is that
48/// a million rows of one table carry one copy of the names between them.
49pub type ColumnNames = alloc::sync::Arc<[Vec<u8>]>;
50
51/// A declared row's values, in declared column order, plus a shared pointer
52/// to its table's column names.
53///
54/// Boxed as one indirection because `Value` is capped at 32 bytes and
55/// `Entry` at 48 — assertions that exist so a new variant cannot quietly
56/// undo the box-collection win, and they caught this one. The row is
57/// therefore two allocations, not one: a 48 B inner and the payload buffer.
58/// Costed against the alternatives before choosing — carrying the names
59/// behind an `Arc` in `Value` is 560 B for the measured row, this is 544 B,
60/// and a bare table id with no names at all would be 496 B but leaves the
61/// rewrite and the snapshot writer unable to name a column, which is the
62/// problem being solved.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct PackedRow(Box<PackedInner>);
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67struct PackedInner {
68    cols: ColumnNames,
69    buf: Box<[u8]>,
70}
71
72impl PackedRow {
73    /// Build from one value per declared column, `None` for an absent one.
74    ///
75    /// `None` back when the payload exceeds [`PACKED_MAX`] or the column
76    /// count exceeds `u16` — the caller keeps the general form.
77    pub fn build(names: &ColumnNames, cols: &[Option<&[u8]>]) -> Option<Self> {
78        debug_assert_eq!(names.len(), cols.len(), "one value slot per declared column");
79        let ncol = u16::try_from(cols.len()).ok()?;
80        let total: usize = cols.iter().flatten().map(|v| v.len()).sum();
81        if total > PACKED_MAX {
82            return None;
83        }
84        let bitmap = ncol.div_ceil(8) as usize;
85        let header = 2 + bitmap + cols.len() * 2;
86        let mut buf = vec![0u8; header + total];
87        buf[..2].copy_from_slice(&ncol.to_le_bytes());
88        let mut end = 0usize;
89        for (i, c) in cols.iter().enumerate() {
90            if let Some(v) = c {
91                buf[2 + i / 8] |= 1 << (i % 8);
92                buf[header + end..header + end + v.len()].copy_from_slice(v);
93                end += v.len();
94            }
95            let at = 2 + bitmap + i * 2;
96            buf[at..at + 2].copy_from_slice(&(end as u16).to_le_bytes());
97        }
98        Some(PackedRow(Box::new(PackedInner { cols: names.clone(), buf: buf.into_boxed_slice() })))
99    }
100
101    /// Declared column count.
102    pub fn columns(&self) -> usize {
103        u16::from_le_bytes([self.0.buf[0], self.0.buf[1]]) as usize
104    }
105
106    /// Whether column `i` is present. Out of range reads as absent.
107    pub fn has(&self, i: usize) -> bool {
108        i < self.columns() && self.0.buf[2 + i / 8] & (1 << (i % 8)) != 0
109    }
110
111    /// The value of the column named `field`, or `None` when the table has
112    /// no such column or this row does not have it.
113    ///
114    /// Linear over the column names, which is the right shape here: a
115    /// declared table has a handful of columns, and a scan of that many
116    /// short slices beats a per-row hash table — the per-row hash table
117    /// being the thing this type exists to delete.
118    pub fn get_named(&self, field: &[u8]) -> Option<&[u8]> {
119        let i = self.0.cols.iter().position(|c| c == field)?;
120        self.get(i)
121    }
122
123    /// Whether the row has a column named `field`.
124    pub fn has_named(&self, field: &[u8]) -> bool {
125        self.0.cols.iter().position(|c| c == field).is_some_and(|i| self.has(i))
126    }
127
128    /// Column `i`'s bytes, or `None` when it is absent or out of range.
129    pub fn get(&self, i: usize) -> Option<&[u8]> {
130        if !self.has(i) {
131            return None;
132        }
133        let bitmap = (self.columns() as u16).div_ceil(8) as usize;
134        let header = 2 + bitmap + self.columns() * 2;
135        let end_at = |j: usize| {
136            let at = 2 + bitmap + j * 2;
137            u16::from_le_bytes([self.0.buf[at], self.0.buf[at + 1]]) as usize
138        };
139        let start = if i == 0 { 0 } else { end_at(i - 1) };
140        Some(&self.0.buf[header + start..header + end_at(i)])
141    }
142
143    /// Overwrite column `i` in place when the new value is exactly as wide as
144    /// the old one, so the offsets do not move.
145    ///
146    /// `false` back when it does not fit that shape — a different width, an
147    /// absent column becoming present, or an index past the end — and the
148    /// caller rebuilds through [`PackedRow::with_column`].
149    ///
150    /// This is an opportunistic fast path, never a property the design
151    /// assumes: whether an update keeps its width is a property of the COLUMN
152    /// (a unix-ms timestamp always does, an enum usually does not, an i64
153    /// counter does until it crosses a digit), and the declaration carries
154    /// types but a type does not fix a width.
155    pub fn set_same_width(&mut self, i: usize, v: &[u8]) -> bool {
156        let Some(old) = self.get(i) else { return false };
157        if old.len() != v.len() {
158            return false;
159        }
160        let bitmap = (self.columns() as u16).div_ceil(8) as usize;
161        let header = 2 + bitmap + self.columns() * 2;
162        let start = if i == 0 {
163            0
164        } else {
165            let at = 2 + bitmap + (i - 1) * 2;
166            u16::from_le_bytes([self.0.buf[at], self.0.buf[at + 1]]) as usize
167        };
168        self.0.buf[header + start..header + start + v.len()].copy_from_slice(v);
169        true
170    }
171
172    /// Replace column `i`, rebuilding the row. `None` back when the result
173    /// would exceed [`PACKED_MAX`].
174    pub fn with_column(&self, i: usize, v: Option<&[u8]>) -> Option<Self> {
175        let mut cols: Vec<Option<&[u8]>> = (0..self.columns()).map(|j| self.get(j)).collect();
176        *cols.get_mut(i)? = v;
177        PackedRow::build(&self.0.cols, &cols)
178    }
179
180    /// The column names this row's table declared.
181    pub fn names(&self) -> &ColumnNames {
182        &self.0.cols
183    }
184
185    /// Field name and value for every present column, in declared order —
186    /// what `HGETALL`, the rewrite and the snapshot writer need.
187    pub fn fields(&self) -> impl Iterator<Item = (&[u8], &[u8])> {
188        (0..self.columns())
189            .filter_map(move |i| Some((self.0.cols.get(i)?.as_slice(), self.get(i)?)))
190    }
191
192    /// Total heap bytes of THIS row — the shared column names are one
193    /// allocation per table and are not charged per row.
194    pub fn heap_bytes(&self) -> usize {
195        self.0.buf.len() + core::mem::size_of::<PackedInner>()
196    }
197
198    /// The number of present columns, for `HLEN`.
199    pub fn len(&self) -> usize {
200        (0..self.columns()).filter(|&i| self.has(i)).count()
201    }
202
203    /// Whether no column is present.
204    pub fn is_empty(&self) -> bool {
205        self.len() == 0
206    }
207}
208
209#[cfg(test)]
210pub(crate) mod tests {
211    use super::*;
212
213    /// `n` throwaway column names — the tests are about the payload layout,
214    /// not about what the columns are called.
215    pub(crate) fn names(n: usize) -> ColumnNames {
216        (0..n).map(|i| format!("c{i}").into_bytes()).collect()
217    }
218
219    #[test]
220    fn round_trips_every_column_including_absent_and_empty() {
221        let cols: Vec<Option<&[u8]>> =
222            vec![Some(&b"id7"[..]), None, Some(&b""[..]), Some(&b"a longer value"[..])];
223        let r = PackedRow::build(&names(cols.len()), &cols).expect("fits");
224        assert_eq!(r.columns(), 4);
225        for (i, want) in cols.iter().enumerate() {
226            assert_eq!(r.get(i), *want, "column {i}");
227        }
228        // Absent and present-but-empty are different, which is what the
229        // bitmap buys over an offset-equality convention.
230        assert!(!r.has(1));
231        assert!(r.has(2));
232        assert_eq!(r.len(), 3);
233    }
234
235    #[test]
236    fn the_cost_scales_with_the_row_rather_than_sitting_on_a_floor() {
237        // The defect being removed: a fixed cost independent of shape.
238        let three = PackedRow::build(&names(3), &[Some(&b"x"[..]); 3]).expect("fits");
239        let twelve = PackedRow::build(&names(12), &[Some(&b"x"[..]); 12]).expect("fits");
240        assert!(
241            twelve.heap_bytes() > three.heap_bytes(),
242            "a wider row must cost more, not the same: {} vs {}",
243            three.heap_bytes(),
244            twelve.heap_bytes()
245        );
246        // Payload buffer plus the boxed inner, and nothing else. The inner is
247        // the price of `Value`'s 32-byte cap; it is a constant, so it does not
248        // reintroduce the floor — it shifts the line the row scales from.
249        let inner = core::mem::size_of::<PackedInner>();
250        assert_eq!(three.heap_bytes(), (2 + 1 + 3 * 2 + 3) + inner);
251        assert_eq!(twelve.heap_bytes(), (2 + 2 + 12 * 2 + 12) + inner);
252    }
253
254    #[test]
255    fn replacing_a_column_leaves_the_others_alone() {
256        let r =
257            PackedRow::build(&names(3), &[Some(&b"a"[..]), Some(&b"bb"[..]), Some(&b"ccc"[..])])
258                .expect("fits");
259        let r2 = r.with_column(1, Some(b"REPLACED")).expect("fits");
260        assert_eq!(r2.get(0), Some(&b"a"[..]));
261        assert_eq!(r2.get(1), Some(&b"REPLACED"[..]));
262        assert_eq!(r2.get(2), Some(&b"ccc"[..]));
263        let r3 = r.with_column(0, None).expect("fits");
264        assert!(!r3.has(0));
265        assert_eq!(r3.get(2), Some(&b"ccc"[..]));
266    }
267
268    #[test]
269    fn an_in_place_write_touches_exactly_one_column() {
270        // The failure this guards is not "the write did not happen" — it is a
271        // write that lands one column over. Every neighbour is checked.
272        let n = names(4);
273        let mut r = PackedRow::build(
274            &n,
275            &[Some(&b"aaa"[..]), Some(&b"bbb"[..]), Some(&b"ccc"[..]), Some(&b"ddd"[..])],
276        )
277        .expect("fits");
278        assert!(r.set_same_width(1, b"XXX"), "same width goes in place");
279        assert_eq!(r.get(0), Some(&b"aaa"[..]), "left neighbour untouched");
280        assert_eq!(r.get(1), Some(&b"XXX"[..]));
281        assert_eq!(r.get(2), Some(&b"ccc"[..]), "right neighbour untouched");
282        assert_eq!(r.get(3), Some(&b"ddd"[..]));
283        // The first and last columns are the ones an off-by-one reaches past.
284        assert!(r.set_same_width(0, b"ZZZ"));
285        assert_eq!(r.get(0), Some(&b"ZZZ"[..]));
286        assert_eq!(r.get(1), Some(&b"XXX"[..]));
287        assert!(r.set_same_width(3, b"WWW"));
288        assert_eq!(r.get(2), Some(&b"ccc"[..]));
289        assert_eq!(r.get(3), Some(&b"WWW"[..]));
290    }
291
292    #[test]
293    fn an_in_place_write_refuses_anything_that_would_move_an_offset() {
294        let n = names(3);
295        let mut r =
296            PackedRow::build(&n, &[Some(&b"aa"[..]), None, Some(&b"cc"[..])]).expect("fits");
297        assert!(!r.set_same_width(0, b"aaa"), "wider must rebuild");
298        assert!(!r.set_same_width(0, b"a"), "narrower must rebuild");
299        assert!(!r.set_same_width(1, b"xx"), "an absent column must rebuild");
300        assert!(!r.set_same_width(9, b"xx"), "out of range");
301        // And a refusal changes nothing.
302        assert_eq!(r.get(0), Some(&b"aa"[..]));
303        assert_eq!(r.get(2), Some(&b"cc"[..]));
304        assert!(!r.has(1));
305    }
306
307    #[test]
308    fn looks_a_column_up_by_the_name_the_wire_uses() {
309        let n: ColumnNames = vec![b"id".to_vec(), b"name".to_vec(), b"dept".to_vec()].into();
310        let r = PackedRow::build(&n, &[Some(&b"7"[..]), None, Some(&b"eng"[..])]).expect("fits");
311        assert_eq!(r.get_named(b"id"), Some(&b"7"[..]));
312        assert_eq!(r.get_named(b"dept"), Some(&b"eng"[..]));
313        // Declared but absent on this row, and undeclared, both read as None
314        // — but only the first is a column of the table.
315        assert_eq!(r.get_named(b"name"), None);
316        assert!(!r.has_named(b"name"));
317        assert_eq!(r.get_named(b"nosuch"), None);
318        assert!(!r.has_named(b"nosuch"));
319    }
320
321    #[test]
322    fn refuses_a_payload_it_cannot_address() {
323        let big = vec![0u8; PACKED_MAX + 1];
324        assert!(PackedRow::build(&names(1), &[Some(&big[..])]).is_none());
325        let just = vec![0u8; PACKED_MAX];
326        assert!(PackedRow::build(&names(1), &[Some(&just[..])]).is_some());
327    }
328}
329
330#[cfg(test)]
331mod cost_tests {
332    use super::tests::names;
333    use super::*;
334
335    /// The claim this type exists for, as arithmetic rather than prose.
336    ///
337    /// Today a promoted hash costs a 16-slot table (16 × 48 slot bytes plus
338    /// 16 + 16 metadata = 800 B requested), an `ArcInner` plus the map struct
339    /// (72 B), and a separate chunk for any value past the inline threshold.
340    /// A packed row is one buffer.
341    #[test]
342    fn a_packed_row_costs_less_than_the_table_it_replaces() {
343        const TABLE_REQUEST: usize = 16 * 48 + 16 + 16; // slots + metadata
344        const ARC_AND_MAP: usize = 16 + 56;
345        for (ncol, vlen) in [(3usize, 400usize), (7, 400), (12, 400)] {
346            let v = vec![b'x'; vlen / ncol];
347            let cols: Vec<Option<&[u8]>> = (0..ncol).map(|_| Some(&v[..])).collect();
348            let packed = PackedRow::build(&names(ncol), &cols).expect("fits").heap_bytes();
349            let today = TABLE_REQUEST + ARC_AND_MAP + vlen;
350            assert!(
351                packed * 2 < today,
352                "{ncol} columns: packed {packed} B is not less than half of today's {today} B"
353            );
354        }
355    }
356
357    /// And — the point of the finding — the cost must MOVE with the shape.
358    #[test]
359    fn the_cost_is_not_flat_in_the_column_count() {
360        let v = [b'x'; 32];
361        let w = |n: usize| {
362            PackedRow::build(&names(n), &(0..n).map(|_| Some(&v[..])).collect::<Vec<_>>())
363                .expect("fits")
364                .heap_bytes()
365        };
366        let (a, b) = (w(3), w(12));
367        // Nine more columns of 32 bytes each, plus nine more ends.
368        assert_eq!(b - a, 9 * (32 + 2) + 1, "growth is payload + ends + bitmap byte");
369    }
370}
371
372impl crate::Store {
373    /// Whether `key` currently holds the packed representation.
374    ///
375    /// For tests and for `MEMORY`-style introspection only. The
376    /// representation is deliberately invisible on the wire; a caller that
377    /// branched on it would be depending on something it must not, and the
378    /// parity tests exist precisely to prove nothing needs to.
379    #[doc(hidden)]
380    pub fn is_packed(&mut self, key: &[u8]) -> bool {
381        matches!(self.live_entry(key).map(|e| &e.value), Some(crate::Value::PackedRow(_)))
382    }
383
384    /// Whether a row under a declared prefix may take the packed form.
385    pub fn packed_rows_enabled(&self) -> bool {
386        self.packed_rows
387    }
388
389    /// Allow rows under a declared prefix to take the packed representation.
390    ///
391    /// Off by default, and settable at runtime, so the two representations
392    /// can be compared with the SAME binary — one flag apart rather than two
393    /// builds apart.
394    pub fn set_packed_rows(&mut self, on: bool) {
395        self.packed_rows = on;
396    }
397
398    /// Whether `key` already holds the packed form — the common case on every
399    /// write after the first, so it is checked before anything reads the row.
400    fn already_packed(&mut self, key: &[u8]) -> bool {
401        self.is_packed(key)
402    }
403
404    /// Convert `key`'s hash into the packed form for a table declaring
405    /// `names`, if it is a hash that is not packed already.
406    ///
407    /// A value the row holds under a name the table does not declare would be
408    /// lost, so its presence refuses the conversion outright and the row keeps
409    /// the general form. Nothing here may drop a value.
410    pub fn pack_row(&mut self, key: &[u8], names: &[Vec<u8>]) {
411        if self.already_packed(key) {
412            return;
413        }
414        let Ok(Some(pairs)) = self.hash_pairs(key) else { return };
415        if pairs.iter().any(|(f, _)| !names.iter().any(|n| n == f)) {
416            return;
417        }
418        let cols: Vec<Option<&[u8]>> = names
419            .iter()
420            .map(|n| pairs.iter().find(|(f, _)| f == n).map(|(_, v)| v.as_slice()))
421            .collect();
422        let shared: ColumnNames = names.to_vec().into();
423        let Some(row) = PackedRow::build(&shared, &cols) else { return };
424        if let Some(e) = self.live_entry_mut(key) {
425            e.value = crate::Value::PackedRow(row);
426        }
427        self.reweigh_entry(key);
428    }
429}