Measure what adding a bytes slot does to records already written

Four of cultnet-rs's ten DatabaseEntry byte fields lack
#[cultcache(key = N, bytes)] and so serialize as MessagePack
array-of-integers rather than bin: IdunnDeploymentBrakeRecord.signature,
IdunnDaemonHealthTrustBindingRecord.signer_public_key,
GameCultServiceTrustAnchorRecord.signer_public_key, and
IdunnAuthenticatedProviderHealthProjectionRecord.signature. Converting
them touches live stores, so the question is what breaks.

Measured rather than reasoned, and the answer is the opposite of what I
expected. Reading is compatible in both directions: serde_bytes::ByteBuf
accepts an array marker, and a plain Vec<u8> accepts bin, so the flip is
neither a hard read break nor a one-way door. No dual-read path is
needed and reverting the binary is survivable.

What does change is reproduction. A record written before the flip can
be read but can never re-encode to the bytes it was stored as, and its
canonical digest moves with it. That is the cost, and it lands on
byte-exact store checks and canonical_sha256, not on readability -- so
the migration is a re-encode question, not a compatibility one.

Also pins the size difference the media work is chasing: 406 bytes as an
array against 278 as bin for a 256-byte payload, 1.46x, independently
matching the 1.45x measured on an 848-byte payload.

  cargo test --test byte_slot_migration -> test result: ok. 4 passed; 0 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-10 00:42:26 +02:00
commit 9e6027e8d8

View file

@ -0,0 +1,113 @@
//! Does adding `#[cultcache(key = N, bytes)]` to a field break reading the
//! records already written without it?
//!
//! The attribute changes a slot from a MessagePack array-of-integers (serde's
//! default for `Vec<u8>`) to a real `bin`. Six of cultnet-rs's byte fields
//! already carry it and four do not, so converting the remaining four is a
//! stored-data question before it is a signature question: every record
//! written before the flip holds an array in that slot.
//!
//! Both directions are tested, because a migration has to survive a rollback
//! as well as a roll-forward.
use cultcache_rs::DatabaseEntry;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "test.byte_slot", schema = "test.byte_slot.v1")]
struct ArrayEncoded {
#[cultcache(key = 0)]
schema_version: String,
#[cultcache(key = 1)]
payload: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "test.byte_slot", schema = "test.byte_slot.v1")]
struct BinEncoded {
#[cultcache(key = 0)]
schema_version: String,
#[cultcache(key = 1, bytes)]
payload: Vec<u8>,
}
fn sample() -> Vec<u8> {
(0u8..=255).collect()
}
#[test]
fn the_attribute_is_what_switches_the_wire_shape() {
let array = rmp_serde::to_vec(&ArrayEncoded {
schema_version: "test.byte_slot.v1".into(),
payload: sample(),
})
.expect("array encodes");
let bin = rmp_serde::to_vec(&BinEncoded {
schema_version: "test.byte_slot.v1".into(),
payload: sample(),
})
.expect("bin encodes");
// 256 bytes as `bin` is a 3-byte header plus the payload. As an array it
// is one or two bytes per element, because every value above 0x7f needs a
// u8 marker of its own.
println!("array-encoded {} bytes, bin-encoded {} bytes", array.len(), bin.len());
assert!(
array.len() > bin.len(),
"array {} should exceed bin {}",
array.len(),
bin.len()
);
}
#[test]
fn a_bytes_reader_still_reads_records_written_before_the_attribute() {
let written_before = rmp_serde::to_vec(&ArrayEncoded {
schema_version: "test.byte_slot.v1".into(),
payload: sample(),
})
.expect("array encodes");
// serde_bytes::ByteBuf accepts an array marker as well as bin, so the
// flip is NOT a hard read break. This was worth measuring rather than
// assuming: the opposite would have forced a dual-read path or a rewrite
// migration onto every field with persisted records.
let read_after =
rmp_serde::from_slice::<BinEncoded>(&written_before).expect("bytes reader reads an array");
assert_eq!(read_after.payload, sample());
}
#[test]
fn a_pre_attribute_reader_still_reads_records_written_after_it() {
let written_after = rmp_serde::to_vec(&BinEncoded {
schema_version: "test.byte_slot.v1".into(),
payload: sample(),
})
.expect("bin encodes");
// The rollback direction, so reverting the binary is survivable.
let read_by_old =
rmp_serde::from_slice::<ArrayEncoded>(&written_after).expect("plain reader reads bin");
assert_eq!(read_by_old.payload, sample());
}
#[test]
fn re_encoding_an_old_record_under_the_attribute_does_not_reproduce_its_bytes() {
// Where the cost actually lands. Reading is compatible; reproducing is
// not. Idunn byte-exact-checks its control store by re-encoding what it
// decoded, and canonical_sha256 hashes the same serialization -- so a
// record written before the flip can be read, but can never re-encode to
// the bytes it was stored as, and its canonical digest changes.
let written_before = rmp_serde::to_vec(&ArrayEncoded {
schema_version: "test.byte_slot.v1".into(),
payload: sample(),
})
.expect("array encodes");
let decoded = rmp_serde::from_slice::<BinEncoded>(&written_before).expect("reads");
let re_encoded = rmp_serde::to_vec(&decoded).expect("re-encodes");
assert_ne!(
written_before, re_encoded,
"identical bytes would mean the attribute changed nothing"
);
}