diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 4e97588c..c6d72492 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -16,6 +16,7 @@ Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). - 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. - A cache whose last `pull_all_backing_stores` was refused (a tail, a payload past the nesting bound, a duplicate unique-index value, a retype conflict, a record from the wrong store) refuses every `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch`, `update` and `delete` with `CultCacheLoadRefused` until a pull succeeds. Before, it stayed writable over an empty image, and the next batch write replaced the store with only the new records. +- A cache with a backing store also refuses every write and removal, with `CultCacheLoadRefused`, until it has pulled: a cache that never pulled, or that gained a store since its last pull, holds no image of its stores, and a batch from it replaced a populated store with only the new records. `is_writable` answers false meanwhile. A cache with no store needs no pull. Callers that attach a store and write (cultmesh nodes created with `pull_on_start: false`) must call `pull_all_backing_stores` first. - `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 6a4cb2de..47a3dd18 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2951,9 +2951,10 @@ enum LoadState { Refused(String), } -/// A write or removal refused because the cache's last load of its backing stores was refused. The cache holds no -/// image of those stores, so any write would persist an image that lacks the records it never read. Repair the store -/// named by `reason` (which names a type, a key and a clause, never a payload) and load again. +/// A write or removal refused because the cache holds no image of its backing stores: its last load was refused, or it has +/// not pulled since its stores were attached. Any write would persist an image that lacks the records it never read. +/// Pull; for a refused load, first repair the store named by `reason` (which names a type, a key and a clause, never a +/// payload). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CultCacheLoadRefused { pub reason: String, @@ -3290,9 +3291,12 @@ impl CultCache { /// Whether a write or removal of `(type_id, key)` would be admitted: false when the held payload carries slots past /// its registered type's declared slots, at any depth, or cannot be read by this build at all (the record is - /// read-only), when a store carries a foreign record at the key, or when another registered type holds it. True for a + /// read-only), when the cache holds no image of its stores (see [`CultCacheLoadRefused`]), when a store carries a foreign record at the key, or when another registered type holds it. True for a /// key nothing holds. An unregistered type is an error. Advisory: the write entries ask the same checks themselves. pub fn is_writable(&self, type_id: &str, key: &str) -> Result { + if self.require_loaded().is_err() { + return Ok(false); + } if !matches!(self.held_finding(type_id, key)?, HeldFinding::Free) { return Ok(false); } diff --git a/packages/cultcache-rs/tests/slot_readonly.rs b/packages/cultcache-rs/tests/slot_readonly.rs index 0031e372..cea0853a 100644 --- a/packages/cultcache-rs/tests/slot_readonly.rs +++ b/packages/cultcache-rs/tests/slot_readonly.rs @@ -149,6 +149,7 @@ fn every_read_only_vector_decodes_marked_and_is_not_writable() { let mut cache = registered(); let dir = tempfile::tempdir().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc"))).unwrap(); + cache.pull_all_backing_stores().unwrap(); cache.put_raw_envelope(envelope("k", payload.clone())).unwrap(); assert_eq!(cache.get::("k").unwrap(), Some(constructed()), "{file}"); assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap(), "{file}"); @@ -470,6 +471,7 @@ fn is_writable_is_false_for_a_type_with_no_home_store() { let mut cache = registered(); // One store, routed to SlotUnit only: UnionSurface has no home. cache.add_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc")), [SlotUnit::TYPE]).unwrap(); + cache.pull_all_backing_stores().unwrap(); assert!(cache.put("k", &constructed()).is_err()); assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap()); assert!(cache.is_writable(SlotUnit::TYPE, "k").unwrap()); diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index 67f2b389..bc3aba6d 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -718,6 +718,7 @@ fn write_refused_past_limit() { let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); let at_limit = wrap_chain(126, boxed("0000000000f0", vec![])); assert_eq!(cache.put("at", &at_limit).unwrap(), at_limit); @@ -801,6 +802,7 @@ fn the_raw_doors_hold_the_bound_for_arms_maps_and_arrays() { cache.register_entry_type::().unwrap(); cache.register_entry_type::().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); cache.put_raw_envelope(raw(type_name, "at-raw", at.clone())).unwrap(); cache.put_prepared_batch(vec![raw(type_name, "at-batch", at.clone())]).unwrap(); let stored = cache.snapshot_envelopes();