cube-store-raw: write a v4 image

The addressed layout, with each index entry carrying the class mask its
writer stamped, and the round trip asserted record for record — a writer and
a reader that disagree about a stride agree about nothing, and every value
lands two bytes off while every record still looks like a record.

`entries` must be in (space, key) order, which is not advice: the space table
says where each space's records *begin* and a fixed stride is what turns
`index_off + i * stride` into a place, so an unsorted index answers the wrong
question without failing. A `BTreeMap<(SpaceId, Key), _>` iterates in exactly
that order, which is why the caller does not have to sort.
This commit is contained in:
luulu
2026-09-22 01:02:44 -04:00
parent 98229ddc79
commit d1f15bc9bb
+109
View File
@@ -821,6 +821,77 @@ use alloc::vec::Vec;
/// Each entry is `(SpaceId, 24-byte key, value)`. The kernel driver does NOT
/// use this (it has no heap); `cube-store` does. Provided behind the `alloc`
/// feature so the no_std build stays clean.
/// `alloc`-only: serialize a v4 image — the addressed layout, with each index entry carrying the
/// class mask its writer stamped.
///
/// ```text
/// [header 46] magic, version, curve, extents, counts
/// [space table: space_count x 48] space | first index | records
/// [index: record_count x 42] key | flags(u16) | value offset | value length
/// [values: packed, in index order]
/// ```
///
/// `entries` must be in `(space, key)` order. That is not advice: the space table says where each
/// space's records *begin*, and a fixed stride is what turns `index_off + i * stride` into a place,
/// so an unsorted index is a store that answers the wrong question without failing. A
/// `BTreeMap<(SpaceId, Key), _>` iterates in exactly that order, which is why the caller does not
/// have to sort.
#[cfg(feature = "alloc")]
pub fn serialize_v4(
curve_tag: u8,
entries: &[(SpaceId, [u8; RAW_KEY_LEN], u16, &[u8])],
) -> Vec<u8> {
// The space table comes from the same order the index is written in, so a walk of the store is
// a walk of this table.
let mut spaces: Vec<(&[u8; SPACE_ID_LEN], u64, u64)> = Vec::new();
for (at, (space, _, _, _)) in entries.iter().enumerate() {
match spaces.last_mut() {
Some((last, _, records)) if *last == space.as_bytes() => *records += 1,
_ => spaces.push((space.as_bytes(), at as u64, 1)),
}
}
let values_total: usize = entries.iter().map(|(_, _, _, v)| v.len()).sum();
let index_off = HEADER_LEN_V3 + spaces.len() * SPACE_ENTRY;
let values_off = index_off + entries.len() * INDEX_ENTRY_V4;
let image_bytes = values_off + values_total;
let mut out = vec![0u8; image_bytes];
out[0..4].copy_from_slice(MAGIC);
out[4] = VERSION_V4;
out[5] = curve_tag;
out[6..14].copy_from_slice(&(image_bytes as u64).to_le_bytes());
out[14..22].copy_from_slice(&(entries.len() as u64).to_le_bytes());
out[22..30].copy_from_slice(&(spaces.len() as u64).to_le_bytes());
out[30..38].copy_from_slice(&(index_off as u64).to_le_bytes());
out[38..46].copy_from_slice(&(values_off as u64).to_le_bytes());
let mut at = HEADER_LEN_V3;
for (space, first, records) in &spaces {
out[at..at + SPACE_ID_LEN].copy_from_slice(*space);
out[at + SPACE_ID_LEN..at + SPACE_ID_LEN + 8].copy_from_slice(&first.to_le_bytes());
out[at + SPACE_ID_LEN + 8..at + SPACE_ENTRY].copy_from_slice(&records.to_le_bytes());
at += SPACE_ENTRY;
}
let mut value_at = values_off;
for (i, (_, key, flags, value)) in entries.iter().enumerate() {
let at = index_off + i * INDEX_ENTRY_V4;
out[at..at + RAW_KEY_LEN].copy_from_slice(key);
out[at + RAW_KEY_LEN..at + RAW_KEY_LEN + FLAGS_LEN].copy_from_slice(&flags.to_le_bytes());
// The length is written as eight bytes even though a value's length fits in four: the
// stride is fixed and `values_off` is computed from it, so a shorter field here would leave
// the last entries overlapping the values.
out[at + RAW_KEY_LEN + FLAGS_LEN..at + RAW_KEY_LEN + FLAGS_LEN + 8]
.copy_from_slice(&(value_at as u64).to_le_bytes());
out[at + RAW_KEY_LEN + FLAGS_LEN + 8..at + INDEX_ENTRY_V4]
.copy_from_slice(&(value.len() as u64).to_le_bytes());
out[value_at..value_at + value.len()].copy_from_slice(value);
value_at += value.len();
}
out
}
#[cfg(feature = "alloc")]
pub fn serialize(curve_tag: u8, entries: &[(SpaceId, [u8; RAW_KEY_LEN], &[u8])]) -> Vec<u8> {
let values_total: usize = entries.iter().map(|(_, _, v)| v.len()).sum();
@@ -1024,6 +1095,44 @@ mod tests {
assert_eq!(read, expected, "a v4 image read differently from its records");
}
/// What the v4 writer writes, the readers read: the same records, in the same order, with the
/// masks they were written under.
///
/// A writer and a reader that disagree about a stride agree about nothing, and the failure is
/// silent — every value lands two bytes off and every record still looks like a record. So the
/// round trip is asserted record for record rather than by a length.
#[test]
#[cfg(feature = "alloc")]
fn the_v4_writer_and_its_readers_agree() {
let root = SpaceId::ROOT;
let other = SpaceId::from_bytes([5u8; SPACE_ID_LEN]);
let entries: [(SpaceId, [u8; RAW_KEY_LEN], u16, &[u8]); 4] = [
(root, [0u8; RAW_KEY_LEN], 0, b""),
(root, [1u8; RAW_KEY_LEN], 0x0080, b"an event"),
(root, [2u8; RAW_KEY_LEN], 0, b"plain"),
(other, [3u8; RAW_KEY_LEN], 0x0022, b"mem|error"),
];
let image = serialize_v4(0, &entries);
assert_eq!(parse(&image).unwrap().version, VERSION_V4);
let read: std::vec::Vec<_> = iter_records(&image)
.map(|r| {
r.map(|r| (r.space.to_vec(), r.key.to_vec(), r.flags, r.value.to_vec()))
})
.collect::<Result<_, _>>()
.unwrap();
let expected: std::vec::Vec<_> = entries
.iter()
.map(|(s, k, f, v)| (s.as_bytes().to_vec(), k.to_vec(), *f, v.to_vec()))
.collect();
assert_eq!(read, expected, "the v4 writer and the reader disagree");
// And the geometry says what it wrote, so a reader computes the same addresses.
let geometry = V3::decode(&image).expect("the tables are where the header says");
assert_eq!(geometry.index_stride(), INDEX_ENTRY_V4);
assert_eq!(geometry.record_count, entries.len() as u64);
}
/// A v1 image still reads, and still stops at its zero padding — the rule v2 retired.
#[test]
fn v1_images_still_read() {