Rust mesh: pin that a snapshot spanning backing stores is refused whole #137

Merged
libby merged 1 commit from libby/mesh-snapshot-multistore-pin into main 2026-11-12 10:31:14 +00:00
2 changed files with 48 additions and 2 deletions

View file

@ -11,7 +11,7 @@ only in git history.
Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0).
- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing.
- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores.
- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it.
- `SchemaConflictError` gains the public field `unread_slots: Option<UnreadSlots>`; code that builds it with a struct literal must set it (`None`).
- A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it.
- The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check.

View file

@ -739,7 +739,8 @@ impl CultMeshNode {
/// of unread slots from a newer peer or a payload this build cannot read, or any other the cache judges) applies
/// nothing. A schema id the registry does not know is skipped, except one with a `.vN` suffix, which is taken as
/// registered and refuses the commit when its type is not (C# throws on an unknown schema too). The commit lands in
/// one backing store, so a snapshot spanning stores is refused whole.
/// one backing store, so a snapshot spanning stores is refused whole; C# `CultCache.Commit` refuses a cross-store batch too
/// ("The batch spans A and B; a commit lands in one home store"), so the two agree.
fn apply_raw_snapshot_documents(
&mut self,
documents: Vec<cultnet_rs::CultNetRawDocumentRecord>,
@ -1210,10 +1211,22 @@ mod tests {
body: String,
}
#[derive(Clone, Debug, PartialEq, Eq, cultcache_rs::DatabaseEntry)]
#[cultcache(type = "cultmesh.test.memo", schema = "CultMeshTestMemo")]
struct Memo {
#[cultcache(key = 0)]
body: String,
}
cultmesh_documents!(TestDocuments {
Note => "cultmesh.test.note.v0",
});
cultmesh_documents!(TwoTypeDocuments {
Note => "cultmesh.test.note.v0",
Memo => "cultmesh.test.memo.v0",
});
fn raw_note(key: &str, payload: Vec<u8>) -> cultnet_rs::CultNetRawDocumentRecord {
cultnet_rs::CultNetRawDocumentRecord {
schema_id: "cultmesh.test.note.v0".to_string(),
@ -1255,6 +1268,39 @@ mod tests {
Ok(())
}
#[test]
fn a_snapshot_spanning_two_backing_stores_is_refused_whole_and_applies_nothing() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut node = CultMesh::create_node(temp.path().join("m.cc"), TwoTypeDocuments, Default::default())?;
// A node has one store through the public API; route Memo to a second one by hand (C# refuses the same commit).
node.cache.add_backing_store(
SingleFileMessagePackBackingStore::new(temp.path().join("memo.cc")),
[Memo::TYPE],
)?;
node.cache.pull_all_backing_stores()?;
let plain = |body: &str| rmp_serde::to_vec(&(body,));
let memo = |key: &str, payload| {
let mut record = raw_note(key, payload);
record.schema_id = "cultmesh.test.memo.v0".to_string();
record
};
let error = node
.apply_raw_snapshot_documents(vec![
raw_note("n", plain("note")?),
memo("m", plain("memo")?),
])
.unwrap_err();
assert!(error.to_string().contains("cannot span backing stores"), "{error}");
assert!(node.get::<Note>("n")?.is_none());
assert!(node.get::<Memo>("m")?.is_none());
assert!(!temp.path().join("m.cc").exists() && !temp.path().join("memo.cc").exists());
// Each store alone still takes its own snapshot.
assert_eq!(node.apply_raw_snapshot_documents(vec![memo("m", plain("memo")?)])?, 1);
assert_eq!(node.get_required::<Memo>("m")?.body, "memo");
Ok(())
}
#[test]
fn a_snapshot_applied_to_a_stale_node_keeps_the_records_another_writer_added() -> Result<()> {
let temp = tempfile::tempdir()?;