CultLib/packages/cultcache-rs/src/lib.rs
2026-11-12 03:39:54 +00:00

7953 lines
No EOL
326 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The derives refuse a malformed union at compile time. Each block below is a valid
//! union with one property changed; keys are never 0, so a refusal cannot be a default
//! key colliding with a declared 0. The derive crate's unit tests assert the message of
//! every refusal the derive itself makes; a `compile_fail` block shows only that it fails.
//!
//! ```
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Fine { #[cultcache(key = -1)] A(Arm), #[cultcache(key = 2147483647)] B, #[cultcache(key = 4)] C }
//! ```
//!
//! A variant without a key, first and last:
//!
//! ```compile_fail
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] A(Arm), B }
//! ```
//!
//! ```compile_fail
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Bad { B, #[cultcache(key = 4)] A(Arm) }
//! ```
//!
//! Two variants with one key:
//!
//! ```compile_fail
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 4)] B }
//! ```
//!
//! A key attribute given twice:
//!
//! ```compile_fail
//! use cultcache_rs::CultUnion;
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] #[cultcache(key = 5)] A }
//! ```
//!
//! A key outside Int32:
//!
//! ```compile_fail
//! use cultcache_rs::CultUnion;
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 2147483648)] A }
//! ```
//!
//! A struct variant:
//!
//! ```compile_fail
//! use cultcache_rs::CultUnion;
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] A { id: String }, #[cultcache(key = 5)] B }
//! ```
//!
//! A multi-field tuple variant:
//!
//! ```compile_fail
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] A(Arm, Arm), #[cultcache(key = 5)] B }
//! ```
//!
//! A generic enum:
//!
//! ```compile_fail
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Bad<T> { #[cultcache(key = 4)] A(Arm), #[cultcache(key = 5)] B(std::marker::PhantomData<T>) }
//! ```
//!
//! An arm that is not a slots record (rustc's `SlotRecord` bound, not the derive, refuses it):
//!
//! ```compile_fail
//! use cultcache_rs::CultUnion;
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] A(String) }
//! ```
//!
//! ```compile_fail
//! use cultcache_rs::{CultSlots, CultUnion};
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String }
//! #[derive(CultUnion)]
//! enum Bad { #[cultcache(key = 4)] A(Option<Arm>) }
//! ```
//!
//! A hand-written `SlotRecord` impl, which only the derives provide:
//!
//! ```compile_fail,E0046
//! use cultcache_rs::SlotRecord;
//! struct Sneaky(String);
//! impl SlotRecord for Sneaky {}
//! ```
//!
//! A CultSlots field without a key, a repeated slot, a repeated key attribute, and a generic struct:
//!
//! ```compile_fail
//! use cultcache_rs::CultSlots;
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String, name: String }
//! ```
//!
//! ```compile_fail
//! use cultcache_rs::CultSlots;
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] id: String, #[cultcache(key = 3)] name: String }
//! ```
//!
//! ```compile_fail
//! use cultcache_rs::CultSlots;
//! #[derive(CultSlots)]
//! struct Arm { #[cultcache(key = 3)] #[cultcache(key = 4)] id: String }
//! ```
//!
//! ```compile_fail
//! use cultcache_rs::CultSlots;
//! #[derive(CultSlots)]
//! struct Arm<T> { #[cultcache(key = 3)] id: T }
//! ```
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use anyhow::ensure;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::any::Any;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::marker::PhantomData;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
extern crate self as cultcache_rs;
pub use cultcache_rs_derive::CultSlots;
pub use cultcache_rs_derive::CultUnion;
pub use cultcache_rs_derive::DatabaseEntry;
pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
const TYPE: &'static str;
const SCHEMA_NAME: &'static str = "DatabaseEntry";
/// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`.
const GLOBAL: bool = false;
/// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name.
fn cult_name(&self) -> Option<String> {
None
}
/// The `#[cultcache(index)]` members, the one declaration: each is an alias with the reader of its value. Each is
/// unique: within one type no two records hold one non-blank value of it, and `get_by_index` finds the record by it.
const UNIQUE_INDEXES: &'static [CultIndexMember<Self>] = &[];
}
/// An index alias and how to read its value from a record; `None` is no value.
pub type CultIndexMember<T> = (&'static str, fn(&T) -> Option<String>);
/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member
/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none.
pub trait CultLookupValue {
fn lookup_value(&self) -> Option<String>;
}
impl CultLookupValue for String {
fn lookup_value(&self) -> Option<String> {
Some(self.clone())
}
}
impl<T: CultLookupValue> CultLookupValue for Option<T> {
fn lookup_value(&self) -> Option<String> {
self.as_ref().and_then(CultLookupValue::lookup_value)
}
}
/// C# renders a boolean `True` or `False`; the index text is shared across runtimes.
impl CultLookupValue for bool {
fn lookup_value(&self) -> Option<String> {
Some(if *self { "True" } else { "False" }.to_string())
}
}
macro_rules! lookup_value_by_display {
($($ty:ty),* $(,)?) => {$(
impl CultLookupValue for $ty {
fn lookup_value(&self) -> Option<String> {
Some(self.to_string())
}
}
)*};
}
lookup_value_by_display!(char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
/// A global type already holds a record under another key; a second would leave `get_global` with two answers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CultCacheSecondGlobal {
pub schema_name: String,
pub existing_key: String,
pub key: String,
}
impl std::fmt::Display for CultCacheSecondGlobal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"{} is a global and already has record {}; {} would be a second.",
self.schema_name, self.existing_key, self.key
)
}
}
impl std::error::Error for CultCacheSecondGlobal {}
/// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry`
/// derives implement, and what a `CultUnion` arm must carry.
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a slots record",
label = "a CultUnion arm is a newtype over a #[derive(CultSlots)] struct",
note = "a String, an Option or any other type is not written as an array of slots"
)]
pub trait SlotRecord {
/// Implemented only by the derives, which write the positional array the contract names.
#[doc(hidden)]
fn __slots_array();
}
pub trait CultCacheRegistry {
fn register_entries(&self, cache: &mut CultCache) -> Result<()>;
}
pub trait SoaDocument: DatabaseEntry {
fn soa_columns(rows: &[Self]) -> BTreeMap<&'static str, CultSoaColumnValues>;
}
pub struct CultSoaColumnValues {
type_name: &'static str,
values: Box<dyn Any + Send>,
}
impl CultSoaColumnValues {
pub fn new<TValue: Clone + Send + 'static>(values: Vec<TValue>) -> Self {
Self {
type_name: std::any::type_name::<TValue>(),
values: Box::new(values),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CultSoaColumn<TValue> {
name: String,
values: Vec<TValue>,
}
impl<TValue> CultSoaColumn<TValue> {
pub fn name(&self) -> &str {
&self.name
}
pub fn values(&self) -> &[TValue] {
&self.values
}
}
pub struct CultSoaTable<TDocument> {
keys: Vec<String>,
documents: Vec<TDocument>,
columns: BTreeMap<String, CultSoaColumnValues>,
_document: PhantomData<TDocument>,
}
impl<TDocument> CultSoaTable<TDocument> {
pub fn keys(&self) -> &[String] {
&self.keys
}
pub fn documents(&self) -> &[TDocument] {
&self.documents
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn column<TValue: Clone + 'static>(&self, name: &str) -> Result<CultSoaColumn<TValue>> {
let Some(column) = self.columns.get(name) else {
return Err(anyhow!("SoA table has no column {name:?}"));
};
let Some(values) = column.values.downcast_ref::<Vec<TValue>>() else {
return Err(anyhow!(
"SoA table column {:?} stores {}, not {}",
name,
column.type_name,
std::any::type_name::<TValue>()
));
};
Ok(CultSoaColumn {
name: name.to_string(),
values: values.clone(),
})
}
}
#[macro_export]
macro_rules! cultcache_registry {
($name:ident { $($entry:ty),* $(,)? }) => {
#[derive(Clone, Copy, Debug, Default)]
pub struct $name;
impl $crate::CultCacheRegistry for $name {
fn register_entries(&self, cache: &mut $crate::CultCache) -> ::anyhow::Result<()> {
$(
cache.register_entry_type::<$entry>()?;
)*
Ok(())
}
}
};
}
#[macro_export]
macro_rules! cultcache_soa {
($entry:ty { $($column:literal => $extractor:expr),* $(,)? }) => {
impl $crate::SoaDocument for $entry {
fn soa_columns(rows: &[Self]) -> ::std::collections::BTreeMap<&'static str, $crate::CultSoaColumnValues> {
let mut columns = ::std::collections::BTreeMap::new();
$(
let values = rows.iter().map($extractor).collect::<Vec<_>>();
columns.insert($column, $crate::CultSoaColumnValues::new(values));
)*
columns
}
}
};
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CultCacheEnvelope {
pub key: String,
#[serde(rename = "type")]
pub r#type: String,
#[serde(with = "serde_bytes")]
pub payload: Vec<u8>,
pub stored_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_id: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CultCacheSoaTable {
pub keys: Vec<String>,
pub types: Vec<String>,
pub payloads: Vec<Vec<u8>>,
pub stored_ats: Vec<String>,
pub schema_ids: Vec<Option<String>>,
}
impl CultCacheSoaTable {
pub fn from_envelopes(entries: impl IntoIterator<Item = CultCacheEnvelope>) -> Self {
let mut table = Self::default();
for entry in entries {
table.keys.push(entry.key);
table.types.push(entry.r#type);
table.payloads.push(entry.payload);
table.stored_ats.push(entry.stored_at);
table.schema_ids.push(entry.schema_id);
}
table
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn validate(&self) -> Result<()> {
let len = self.keys.len();
for (name, candidate) in [
("types", self.types.len()),
("payloads", self.payloads.len()),
("storedAts", self.stored_ats.len()),
("schemaIds", self.schema_ids.len()),
] {
if candidate != len {
return Err(anyhow!(
"CultCache SoA column {name} has length {candidate}, expected {len}"
));
}
}
for (index, key) in self.keys.iter().enumerate() {
if key.trim().is_empty() {
return Err(anyhow!("CultCache SoA row {index} has an empty key"));
}
if self.types[index].trim().is_empty() {
return Err(anyhow!("CultCache SoA row {index} has an empty type"));
}
if self.stored_ats[index].trim().is_empty() {
return Err(anyhow!("CultCache SoA row {index} has an empty stored_at"));
}
}
Ok(())
}
pub fn into_envelopes(self) -> Result<Vec<CultCacheEnvelope>> {
self.validate()?;
let Self {
keys,
types,
payloads,
stored_ats,
schema_ids,
} = self;
Ok(keys
.into_iter()
.zip(types)
.zip(payloads)
.zip(stored_ats)
.zip(schema_ids)
.map(
|((((key, r#type), payload), stored_at), schema_id)| CultCacheEnvelope {
key,
r#type,
payload,
stored_at,
schema_id,
},
)
.collect())
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct PersistedStoreSnapshot(
String,
Vec<PersistedSchemaCatalogEntry>,
Vec<PersistedRecord>,
);
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct PersistedSchemaCatalogEntry(
String,
String,
String,
String,
String,
Vec<String>,
Vec<PersistedSchemaCatalogMember>,
);
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct PersistedSchemaCatalogMember(
u32,
String,
String,
bool,
bool,
Option<String>,
bool,
Option<String>,
);
#[derive(Clone, Debug, serde::Serialize)]
struct PersistedRecord(
String,
String,
String,
#[serde(with = "serde_bytes")] Vec<u8>,
);
impl<'de> serde::Deserialize<'de> for PersistedRecord {
/// Reads the four v1 slots and refuses a fifth, naming the record. No runtime reads a
/// v1 store holding such a record, so the store is not a valid one.
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
use serde::de::{Error, IgnoredAny, SeqAccess, Visitor};
struct RecordVisitor;
impl<'de> Visitor<'de> for RecordVisitor {
type Value = PersistedRecord;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a CultCache persisted record of 4 slots")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<PersistedRecord, A::Error> {
let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?;
let payload: serde_bytes::ByteBuf =
seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?;
if seq.next_element::<IgnoredAny>()?.is_some() {
return Err(A::Error::custom(format!(
"{RECORD_REFUSAL}{key:?} (schema {schema_id:?}) has more than the 4 slots of a {STORE_FORMAT_V1} record, so this is not a valid store"
)));
}
Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec()))
}
}
deserializer.deserialize_seq(RecordVisitor)
}
}
const STORE_FORMAT_V1: &str = "cultcache.store.v1";
/// A store that can hold element ids. A reader older than element ids refuses this header: it would skip the id
/// slots of a nested element and rewrite the element without them. This runtime writes it back only for a file it
/// found under it; it does not decide when a document carries ids.
const STORE_FORMAT_ELEMENT_IDS: &str = "cultcache.store.v3";
/// Opens every refusal CultCache raises about one persisted record. The decoder's own
/// messages can quote the bytes; these name only the record's key and schema id.
const RECORD_REFUSAL: &str = "CultCache record ";
/// A store header as a refusal may show it: echoed when it has the shape
/// `cultcache.store.v<digits>`, and otherwise described only by its length, since the
/// bytes are the store's and an error text must not carry them.
fn describe_header(header: &str) -> String {
let known_shape = header
.strip_prefix("cultcache.store.v")
.is_some_and(|version| !version.is_empty() && version.bytes().all(|byte| byte.is_ascii_digit()));
if known_shape {
format!("{header:?}")
} else {
format!("an unrecognised cultcache.store.* header of {} bytes", header.len())
}
}
/// What a failed decode tells the operator: CultCache's own context and record
/// refusals, and for a decoder failure only its kind, never a value from the bytes.
fn describe_undecodable(error: &anyhow::Error) -> String {
let mut parts = Vec::new();
for cause in error.chain() {
if let Some(decode) = cause.downcast_ref::<rmp_serde::decode::Error>() {
parts.push(describe_decode_error(decode));
break;
}
parts.push(cause.to_string());
}
parts.join(": ")
}
/// The deepest a document payload may nest, on read and on write, in every CultCache runtime: every
/// MessagePack array and map counts, read by a deserializer or skipped past it, and the outermost
/// array is depth 1. A payload of this depth is accepted and one container deeper is refused. It is
/// the C# reference's `CultMessagePackSecurity.MaximumPayloadDepth`.
pub const MAX_PAYLOAD_DEPTH: usize = 256;
/// A payload nests more containers than [`MAX_PAYLOAD_DEPTH`]. It names the depth reached and the
/// limit, never payload bytes, and can be recovered from the `anyhow::Error` a read or write returns.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PayloadTooDeep {
pub depth: usize,
pub limit: usize,
}
impl std::fmt::Display for PayloadTooDeep {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"payload nests {} MessagePack containers deep; the limit is {}",
self.depth, self.limit
)
}
}
impl std::error::Error for PayloadTooDeep {}
/// Walks the MessagePack headers of one payload without recursion, counting every array and map and
/// refusing the first container past [`MAX_PAYLOAD_DEPTH`]. Scalars are skipped by length and nothing
/// is allocated per element. A payload that ends early is the decoder's to refuse.
pub fn check_payload_depth(bytes: &[u8]) -> std::result::Result<(), PayloadTooDeep> {
let mut remaining = [0usize; MAX_PAYLOAD_DEPTH];
let mut depth = 0usize;
let mut position = 0usize;
while position < bytes.len() {
let Some((advance, children)) = element_header(bytes, position) else {
return Ok(());
};
position = position.saturating_add(advance);
if let Some(children) = children {
if depth == MAX_PAYLOAD_DEPTH {
return Err(PayloadTooDeep { depth: MAX_PAYLOAD_DEPTH + 1, limit: MAX_PAYLOAD_DEPTH });
}
if children > 0 {
remaining[depth] = children;
depth += 1;
continue;
}
}
// One element is complete; close every container it was the last element of.
while depth > 0 {
remaining[depth - 1] -= 1;
if remaining[depth - 1] != 0 {
break;
}
depth -= 1;
}
if depth == 0 {
return Ok(());
}
}
Ok(())
}
/// The element at `position`: how many bytes to advance past it (its whole length for a scalar, its
/// header for a container) and, for a container, how many child elements follow. `None` when the
/// payload ends inside the header.
fn element_header(bytes: &[u8], position: usize) -> Option<(usize, Option<usize>)> {
// The big-endian length after the code byte.
let length = |width: usize| -> Option<usize> {
let field = bytes.get(position + 1..position + 1 + width)?;
Some(field.iter().fold(0usize, |length, byte| (length << 8) | usize::from(*byte)))
};
let code = bytes[position];
Some(match code {
0x00..=0x7f | 0xe0..=0xff | 0xc0..=0xc3 => (1, None),
0x80..=0x8f => (1, Some(2 * usize::from(code & 0x0f))),
0x90..=0x9f => (1, Some(usize::from(code & 0x0f))),
0xa0..=0xbf => (1 + usize::from(code & 0x1f), None),
0xc4 | 0xd9 => (2usize.saturating_add(length(1)?), None),
0xc5 | 0xda => (3usize.saturating_add(length(2)?), None),
0xc6 | 0xdb => (5usize.saturating_add(length(4)?), None),
0xc7 => (3usize.saturating_add(length(1)?), None),
0xc8 => (4usize.saturating_add(length(2)?), None),
0xc9 => (6usize.saturating_add(length(4)?), None),
0xca | 0xce | 0xd2 => (5, None),
0xcb | 0xcf | 0xd3 => (9, None),
0xcc | 0xd0 => (2, None),
0xcd | 0xd1 | 0xd4 => (3, None),
0xd5 => (4, None),
0xd6 => (6, None),
0xd7 => (10, None),
0xd8 => (18, None),
0xdc => (3, Some(length(2)?)),
0xdd => (5, Some(length(4)?)),
0xde => (3, Some(length(2)?.saturating_mul(2))),
0xdf => (5, Some(length(4)?.saturating_mul(2))),
})
}
/// The stack left below which a decode moves to a fresh segment. Measured (map D10, D12): the
/// distance between two stack checks is 14 times the inline size of the record entered plus 3 KiB in
/// a debug build and 6 times in release, so at [`MAX_RECORD_INLINE_BYTES`] it is 220 KiB and 93 KiB;
/// 512 KiB is 2.3 times the larger. A record the derives produced can never exceed that bound.
const DECODE_RED_ZONE: usize = 512 * 1024;
/// The size of each segment a decode grows onto, four red zones. It does not change the cost of a
/// decode (map D6); it only has to exceed the red zone plus one gap.
const DECODE_STACK_SEGMENT: usize = 2 * 1024 * 1024;
/// The largest `size_of` a record the derives produce may have. `DatabaseEntry`, `CultSlots` and
/// `CultUnion` refuse a larger one at compile time: put its large data behind a `Vec` or a `Box`.
///
/// Decoding builds a record in stack frames that hold it, and the distance between two stack checks
/// grows with its size, so the decode's red zone is set against a record of exactly this size. A
/// field of a type the derives did not produce (a plain serde struct, a large tuple) that exceeds
/// this size behind a `Box` or `Vec` is outside the guarantee.
///
/// ```compile_fail,E0080
/// use cultcache_rs::CultSlots;
///
/// #[derive(CultSlots)]
/// struct TooLarge {
/// #[cultcache(key = 0)]
/// a: [[u64; 32]; 32],
/// #[cultcache(key = 1)]
/// b: [[u64; 32]; 32],
/// #[cultcache(key = 2)]
/// one_more: u8,
/// }
/// ```
///
/// A union is refused by its own size, though every arm is within the limit:
///
/// ```compile_fail,E0080
/// use cultcache_rs::{CultSlots, CultUnion};
///
/// #[derive(CultSlots)]
/// struct Arm {
/// #[cultcache(key = 0)]
/// a: [[u64; 32]; 32],
/// #[cultcache(key = 1)]
/// b: [[u64; 32]; 32],
/// }
///
/// #[derive(CultUnion)]
/// enum TooLarge {
/// #[cultcache(key = 0)]
/// Arm(Arm),
/// #[cultcache(key = 1)]
/// Leaf,
/// }
/// ```
///
/// A record of exactly the limit, a union over a record of exactly the limit's half, and a record
/// under it with a `Vec` for its large data compile:
///
/// ```
/// use cultcache_rs::{CultSlots, CultUnion};
///
/// #[derive(CultSlots)]
/// struct AtTheLimit {
/// #[cultcache(key = 0)]
/// a: [[u64; 32]; 32],
/// #[cultcache(key = 1)]
/// b: [[u64; 32]; 32],
/// }
///
/// #[derive(CultSlots)]
/// struct Half {
/// #[cultcache(key = 0)]
/// a: [[u64; 32]; 32],
/// }
///
/// #[derive(CultUnion)]
/// enum Fits {
/// #[cultcache(key = 0)]
/// Half(Half),
/// #[cultcache(key = 1)]
/// Leaf,
/// }
///
/// #[derive(CultSlots)]
/// struct Boxed {
/// #[cultcache(key = 0)]
/// large: Vec<u8>,
/// #[cultcache(key = 1)]
/// header: [[u64; 32]; 31],
/// }
/// ```
pub const MAX_RECORD_INLINE_BYTES: usize = 16 * 1024;
/// The one grown setup for every decode of stored bytes: the outer stack check, then `serde_stacker`
/// re-checks at every container serde's impls descend through, skipped values included. `body` runs on the grown stack with the
/// grown deserializer over `reader`, so a caller that must read `reader`'s position afterwards (the store header) still can.
fn grown<R, T>(
reader: &mut rmp_serde::Deserializer<R>,
body: impl FnOnce(serde_stacker::Deserializer<&mut rmp_serde::Deserializer<R>>) -> T,
) -> T {
stacker::maybe_grow(DECODE_RED_ZONE, DECODE_STACK_SEGMENT, || {
let mut grown = serde_stacker::Deserializer::new(reader);
grown.red_zone = DECODE_RED_ZONE;
grown.stack_size = DECODE_STACK_SEGMENT;
body(grown)
})
}
/// The one grown decoder of a value from stored bytes. No nesting bound is applied here; [`decode_payload`] walks first.
fn decode_grown<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> std::result::Result<T, rmp_serde::decode::Error> {
let mut reader = rmp_serde::Deserializer::from_read_ref(bytes);
grown(&mut reader, |grown| T::deserialize(grown))
}
/// The one decode of a document payload: the nesting bound first, then [`decode_grown`], whose
/// rmp-serde depth counter (1024) is only a backstop. A failure keeps its kind and never quotes the
/// bytes. Readers of stored envelope payloads outside this crate decode through it too.
///
/// A payload inside the bound decodes on any thread that has the decoder's fixed floor, which no payload
/// changes: at the bound, about 56 KiB in release and 72 KiB in debug for a caller that only tests the
/// result (`is_ok`), plus the caller's own frames. The figure depends on how the caller holds the result:
/// one that moves the record out holds a second copy and needs more (about 104 KiB in debug). That holds
/// for every record the derives produce, which [`MAX_RECORD_INLINE_BYTES`] bounds. Stack growth was
/// verified on x86_64-pc-windows-msvc by hand (`decode_stack`, revision 10a39dac, 13 tests, debug and release); a
/// `stacker` bump or a change to the stack-size tests repeats it.
pub fn decode_payload<T: serde::de::DeserializeOwned>(payload: &[u8]) -> Result<T> {
check_payload_depth(payload)?;
decode_grown(payload).map_err(opaque_decode_error)
}
/// What a payload decode found past the slots this build declares, at any depth: the first type read with extra slots,
/// how many slots it carried against how many it declares, and how many objects in the payload did. It holds no slot
/// value, so a refusal can name it. A record whose payload carries it is read-only in the cache
/// ([`CultCache::is_writable`]); this runtime has no compatible schema ids, so every mark is unconsented.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnreadSlots {
/// The first type read with extra slots (a unit arm is named by its variant).
pub type_name: String,
/// The slots that object carried.
pub slot_count: usize,
/// The slots this build declares for it.
pub declared_count: usize,
/// The objects in the payload that carried extra slots.
pub occurrences: usize,
}
/// The open mark scope of a decode: how many slot objects it has entered, and the mark of the first entered one that
/// carried extra slots (the reference names the first type read, which is the outermost: it is entered before its members).
#[derive(Default)]
struct MarkScope {
entered: usize,
mark: Option<(usize, UnreadSlots)>,
}
thread_local! {
/// The open mark scope of a decode on this thread: `None` when no decode entry has one open, so a decode through
/// serde alone notes nothing. The slot visitors the derives write note into it.
static UNREAD_SLOTS: std::cell::RefCell<Option<MarkScope>> = const { std::cell::RefCell::new(None) };
}
/// The derives' sensor, first half: a slot object is entered. Its ticket orders it among the objects of the decode.
#[doc(hidden)]
pub fn __enter_slots() -> usize {
UNREAD_SLOTS.with(|scope| {
scope.borrow_mut().as_mut().map_or(0, |open| {
open.entered += 1;
open.entered
})
})
}
/// The derives' sensor, second half: the object entered as `ticket`, of `type_name`, carried `carried` slots where this
/// build declares `declared`.
#[doc(hidden)]
pub fn __note_unread_slots(ticket: usize, type_name: &str, carried: usize, declared: usize) {
UNREAD_SLOTS.with(|scope| {
let mut scope = scope.borrow_mut();
let Some(open) = scope.as_mut() else {
return;
};
let occurrences = open.mark.as_ref().map_or(0, |(_, found)| found.occurrences) + 1;
if open.mark.as_ref().is_none_or(|(first, _)| ticket < *first) {
let named = UnreadSlots { type_name: type_name.to_string(), slot_count: carried, declared_count: declared, occurrences };
open.mark = Some((ticket, named));
} else if let Some((_, found)) = open.mark.as_mut() {
found.occurrences = occurrences;
}
});
}
/// A unit arm is a zero-slot record: its slots array is read and counted, not refused.
#[doc(hidden)]
pub struct __UnitArmSlots {
ticket: usize,
extra: usize,
}
impl __UnitArmSlots {
/// Notes the arm if its array held any element.
pub fn note(self, variant: &str) {
if self.extra > 0 {
__note_unread_slots(self.ticket, variant, self.extra, 0);
}
}
}
impl<'de> serde::Deserialize<'de> for __UnitArmSlots {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct Count;
impl<'de> serde::de::Visitor<'de> for Count {
type Value = __UnitArmSlots;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a slots array")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
let ticket = __enter_slots();
let mut extra = 0usize;
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {
extra += 1;
}
Ok(__UnitArmSlots { ticket, extra })
}
}
deserializer.deserialize_seq(Count)
}
}
/// The scope one marked decode opens on this thread; closed when it ends, whatever the decode did. A marked decode is
/// never re-entered (only the cache's own checks open one), so scopes do not nest.
struct UnreadScope;
impl UnreadScope {
fn open() -> Self {
UNREAD_SLOTS.with(|scope| *scope.borrow_mut() = Some(MarkScope::default()));
Self
}
fn mark(&self) -> Option<UnreadSlots> {
UNREAD_SLOTS.with(|scope| scope.borrow_mut().as_mut().and_then(|open| open.mark.take()).map(|(_, mark)| mark))
}
}
impl Drop for UnreadScope {
fn drop(&mut self) {
UNREAD_SLOTS.with(|scope| *scope.borrow_mut() = None);
}
}
/// [`decode_payload`] with the mark: the same depth check and grown decode, inside a scope the derives' slot
/// visitors note into. A payload past the nesting limit is refused before the decode, never marked.
pub fn decode_payload_marked<T: serde::de::DeserializeOwned>(payload: &[u8]) -> Result<(T, Option<UnreadSlots>)> {
check_payload_depth(payload)?;
let scope = UnreadScope::open();
let value = decode_grown(payload).map_err(opaque_decode_error)?;
Ok((value, scope.mark()))
}
/// The registered check of a held payload: monomorphized by [`CultCache::register_entry_type`] so the untyped write
/// entries can decode a payload they cannot name.
/// A payload the type cannot decode at all (an arm this build lacks, a corrupt record) is an error here, and the held
/// record is read-only: the cache cannot tell what a writer of another build stored in it.
fn unread_slots_of<T: serde::de::DeserializeOwned>(payload: &[u8]) -> Result<Option<UnreadSlots>> {
decode_payload_marked::<T>(payload).map(|(_, mark)| mark)
}
/// What the cache finds in a held payload when asked to write or remove its key.
enum HeldFinding {
Free,
Marked(UnreadSlots),
Unreadable(anyhow::Error),
}
/// What a registered type is to the cache: its schema name and how to read the slots a held payload carries.
struct Definition {
schema_name: &'static str,
unread_slots: fn(&[u8]) -> Result<Option<UnreadSlots>>,
}
/// The nesting bound for an envelope admitted without a typed decode, from the same walk that
/// [`decode_payload`] runs first. Names the entry's type and key, never the payload.
fn require_payload_depth(entry: &CultCacheEnvelope) -> Result<()> {
check_payload_depth(&entry.payload).with_context(|| {
format!(
"refused CultCache envelope {:?} at key {:?}",
entry.r#type, entry.key
)
})
}
/// A decoder failure as an error that keeps only its kind. Every decode of stored bytes
/// goes through this or [`describe_undecodable`], so no error text quotes a stored value.
fn opaque_decode_error(error: rmp_serde::decode::Error) -> anyhow::Error {
anyhow!(describe_decode_error(&error))
}
/// The text of a decode failure. Field names in it come from the type, never from the bytes.
fn describe_decode_error(error: &rmp_serde::decode::Error) -> String {
use rmp_serde::decode::Error;
match error {
Error::InvalidMarkerRead(_) | Error::InvalidDataRead(_) => "the bytes end before the store does",
Error::TypeMismatch(_) => "a value has a MessagePack type the store format does not allow",
Error::OutOfRange => "a number is out of range for its slot",
Error::LengthMismatch(_) => "an array has a length the store format does not allow",
Error::Utf8Error(_) => "a string is not valid UTF-8",
Error::DepthLimitExceeded => "values are nested deeper than the decoder allows",
Error::Syntax(message) | Error::Uncategorized(message) if message.starts_with(RECORD_REFUSAL) => {
return message.clone();
}
Error::Syntax(message) | Error::Uncategorized(message) => {
return describe_serde_message(message);
}
}
.to_string()
}
/// serde's own texts, kept only where they cannot carry a received value: a missing or duplicated
/// field is named by the type's static field name (checked to look like one), an unknown tag and a
/// mistyped value are described by kind. Everything else, `unknown field` included, is opaque.
fn describe_serde_message(message: &str) -> String {
for lead in ["missing field `", "duplicate field `"] {
if let Some(name) = message.strip_prefix(lead).and_then(|rest| rest.strip_suffix('`')) {
let is_name = (1..=64).contains(&name.len())
&& name.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
if is_name {
return message.to_string();
}
}
}
if message.starts_with("unknown variant ") {
"a tag names no variant this type knows".to_string()
} else if message.starts_with("invalid type: ") {
"a value has a MessagePack type the store format does not allow".to_string()
} else {
"a value does not fit the store format".to_string()
}
}
/// What one pass over a file's bytes learned about its header. `first` is the leading slot when it is a MessagePack
/// string (`str`, never `bin`, as in every other runtime), and `None` when the bytes do not open with an array whose first
/// slot is a string (a legacy envelope array candidate). `whole` is the proof that the bytes are exactly one complete
/// MessagePack array: a truncated store or bytes after the array fail it, and are never taken for a legacy file. A store cut
/// short after its header string still names its format, so `first` survives a failed `whole` and a newer store is refused
/// as unsupported, not as damaged.
struct StoreHeader {
first: Option<String>,
whole: Result<()>,
}
/// The one reader of a store file's header. Nothing else classifies leading bytes.
fn store_header(bytes: &[u8]) -> StoreHeader {
/// A string slot and only a string: MessagePack `bin` is not a header.
fn text<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<String, D::Error> {
struct TextVisitor;
impl serde::de::Visitor<'_> for TextVisitor {
type Value = String;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a MessagePack string")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<String, E> {
Ok(value.to_string())
}
}
deserializer.deserialize_any(TextVisitor)
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum FirstSlot {
Text(#[serde(deserialize_with = "text")] String),
Other(serde::de::IgnoredAny),
}
struct HeaderVisitor<'a>(&'a mut Option<String>);
impl<'de> serde::de::Visitor<'de> for HeaderVisitor<'_> {
type Value = ();
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an array whose first slot may be the store header")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<(), A::Error> {
if let Some(FirstSlot::Text(header)) = seq.next_element::<FirstSlot>()? {
*self.0 = Some(header);
}
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(())
}
}
let mut first = None;
let mut cursor = std::io::Cursor::new(bytes);
// Walked on the grown stack, as every other decode of stored bytes is: a store may nest values deeper than the thread allows.
let walked = {
let mut reader = rmp_serde::Deserializer::new(&mut cursor);
grown(&mut reader, |grown| serde::Deserializer::deserialize_seq(grown, HeaderVisitor(&mut first)))
};
let after = bytes.len() - cursor.position() as usize;
let whole = walked
.map_err(|error| opaque_decode_error(error).context("the store is not one complete MessagePack array"))
.and_then(|()| {
ensure!(after == 0, "the store has {after} bytes after its MessagePack array");
Ok(())
});
StoreHeader { first, whole }
}
/// A write the catalog cannot describe: records of different types under one schema id. This writer derives one entry per carried
/// schema id from its records, so that is its only conflict besides a single-file write that would put a second type under a
/// key the store already holds (the key is the record's identity there). Nothing is written. It names the id, the two names and a record key.
#[derive(Debug)]
pub struct SchemaConflictError {
pub schema_id: String,
pub schema_names: Vec<String>,
pub record_key: String,
/// Set when the refusal is a write or removal of a record held read-only for the slots its payload carries past
/// this build's; `None` for every other refusal.
pub unread_slots: Option<UnreadSlots>,
}
impl std::fmt::Display for SchemaConflictError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(unread) = &self.unread_slots {
return write!(
formatter,
"{} record {} is read-only: {} was read with {} slots where this build declares {} ({} object(s) in the record). \
Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.",
self.schema_names.first().map_or("", String::as_str),
self.record_key,
unread.type_name,
unread.slot_count,
unread.declared_count,
unread.occurrences
);
}
write!(
formatter,
"schema id {:?} cannot describe record {:?}: {:?}",
self.schema_id, self.record_key, self.schema_names
)
}
}
impl std::error::Error for SchemaConflictError {}
/// A single-file store whose bytes were read and not accepted. It sits in the error
/// chain of every such read, so a consumer tells it apart with
/// `error.downcast_ref::<CultCacheStoreUnreadable>()`. A failure to read the bytes
/// at all stays a [`std::io::Error`] in the chain and never carries this type; the
/// chain under this type never holds an `io::Error`, so the two never overlap.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CultCacheStoreUnreadable {
pub path: PathBuf,
pub kind: CultCacheStoreUnreadableKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CultCacheStoreUnreadableKind {
/// The bytes are not a store this runtime's format accepts: truncated, garbled,
/// or a record the format does not allow.
Undecodable,
/// The header names a store format this runtime does not read, such as one
/// written by a newer runtime. The rest of the file was not judged.
UnsupportedFormat,
}
impl std::fmt::Display for CultCacheStoreUnreadable {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.kind {
CultCacheStoreUnreadableKind::Undecodable => {
write!(formatter, "CultCache store {} is not decodable", self.path.display())
}
CultCacheStoreUnreadableKind::UnsupportedFormat => write!(
formatter,
"CultCache store {} is in a format this runtime does not read",
self.path.display()
),
}
}
}
impl std::error::Error for CultCacheStoreUnreadable {}
/// A write to a single-file store that failed, and what it left on disk. It sits in the
/// error chain of every such failure, above the cause, so a consumer tells the outcomes
/// apart with `error.downcast_ref::<CultCacheStoreWriteFailed>()`.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CultCacheStoreWriteFailed {
pub path: PathBuf,
pub kind: CultCacheStoreWriteFailedKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CultCacheStoreWriteFailedKind {
/// The entries cannot be stored as a snapshot, for example two document types
/// claim one schema id. Nothing was written, and retrying the same entries fails
/// the same way: this is a refusal, not an I/O failure.
Rejected,
/// The write failed before the store file was replaced (creating, writing or
/// syncing the staging file, or renaming it): the file holds what it held before.
NotReplaced,
/// The store file was replaced, then syncing its directory failed: the new
/// snapshot is in place but may not survive a crash.
ReplacedNotDurable,
}
impl std::fmt::Display for CultCacheStoreWriteFailed {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.kind {
CultCacheStoreWriteFailedKind::Rejected => {
write!(formatter, "CultCache store {} refused a snapshot it cannot store", self.path.display())
}
CultCacheStoreWriteFailedKind::NotReplaced => {
write!(formatter, "CultCache store {} was not replaced", self.path.display())
}
CultCacheStoreWriteFailedKind::ReplacedNotDurable => write!(
formatter,
"CultCache store {} was replaced, but the replacement is not yet durable",
self.path.display()
),
}
}
}
impl std::error::Error for CultCacheStoreWriteFailed {}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PushAllOptions {
/// Seed the store only where nothing is: a single-file flush writes when nothing is at the path, leaves a store it can
/// read byte for byte, and refuses what its reader refuses (a dangling link included), as a plain flush refuses.
pub soft: bool,
}
/// One per-entry expectation for [`SingleFileMessagePackBackingStore::compare_exchange`].
/// `current` is the envelope the caller believes is stored, or `None` when it
/// believes the identity is absent.
#[derive(Clone, Debug, PartialEq)]
pub struct CultCacheExpectedEnvelope {
pub key: String,
pub r#type: String,
pub current: Option<CultCacheEnvelope>,
}
/// Result of one nonblocking whole-snapshot compare-and-exchange. Contention is
/// reported rather than waited on, so the caller keeps retry and scheduling
/// authority instead of blocking inside the store.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TryCompareExchangeSnapshotOutcome {
Exchanged,
Mismatch,
LockContended,
}
pub trait CacheBackingStore: Send {
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>>;
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()>;
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()>;
/// Replaces everything the store holds with `entries` in one atomic step:
/// on `Err` the store still holds exactly what it held before. A cache
/// batch (`put_prepared_batch`) is all-or-nothing only through this. There
/// is no default, because a replace assembled from `delete` and `push`
/// calls leaves the store emptied or partial when one of them fails; a
/// store without an atomic replace does not compile:
///
/// ```compile_fail,E0046
/// use cultcache_rs::{CacheBackingStore, CultCacheEnvelope};
///
/// struct PerRecordStore;
///
/// impl CacheBackingStore for PerRecordStore {
/// fn pull_all(&self) -> anyhow::Result<Vec<CultCacheEnvelope>> {
/// Ok(Vec::new())
/// }
/// fn push(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> {
/// Ok(())
/// }
/// fn delete(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> {
/// Ok(())
/// }
/// }
/// ```
fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SingleFileMessagePackBackingStore {
path: PathBuf,
}
impl SingleFileMessagePackBackingStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
/// Holds the pre-created sibling lock shared while a read-only consumer
/// evaluates and acts on one snapshot. The reader never creates or writes
/// either file. Writers using the sibling lock cannot replace the snapshot
/// until `action` returns.
///
/// Releasing the lock is RAII cleanup, under the same policy as every lock in
/// this crate (see `release_lock`): a release failure cannot rewrite the
/// completed action's result. This path alone also offers an observer for that
/// failure, [`Self::with_read_only_shared_snapshot_and_unlock_diagnostic`],
/// because a provider crossing may need to report it; the locked read and write
/// paths discard it.
pub fn with_read_only_shared_snapshot<T>(
&self,
action: impl FnOnce(Vec<CultCacheEnvelope>) -> Result<T>,
) -> Result<T> {
self.with_read_only_shared_snapshot_and_unlock_diagnostic(action, |_| {})
}
/// The diagnostic-bearing form of
/// [`Self::with_read_only_shared_snapshot`]. `on_unlock_failure` observes a
/// cleanup failure separately and never changes the action result.
pub fn with_read_only_shared_snapshot_and_unlock_diagnostic<T>(
&self,
action: impl FnOnce(Vec<CultCacheEnvelope>) -> Result<T>,
on_unlock_failure: impl FnOnce(anyhow::Error),
) -> Result<T> {
self.with_read_only_shared_snapshot_using(
action,
|lock| {
fs2::FileExt::unlock(lock)
.with_context(|| format!("failed to unlock {}", self.lock_path().display()))
},
on_unlock_failure,
)
}
fn with_read_only_shared_snapshot_using<T, U, D>(
&self,
action: impl FnOnce(Vec<CultCacheEnvelope>) -> Result<T>,
unlock: U,
on_unlock_failure: D,
) -> Result<T>
where
U: FnOnce(&File) -> Result<()>,
D: FnOnce(anyhow::Error),
{
let lock_path = self.lock_path();
let lock = OpenOptions::new()
.read(true)
.open(&lock_path)
.with_context(|| format!("failed to open pre-created {}", lock_path.display()))?;
fs2::FileExt::lock_shared(&lock)
.with_context(|| format!("failed to lock {}", lock_path.display()))?;
let guard = ReadOnlySharedLockGuard::new(lock, unlock, on_unlock_failure);
let result = self.read_all_unlocked().and_then(action);
drop(guard);
result
}
/// Replaces one exact envelope under the store's cross-process exclusive
/// lock. A changed or missing expected envelope returns false without a write.
pub fn compare_and_swap_entry(
&self,
expected: &CultCacheEnvelope,
replacement: CultCacheEnvelope,
) -> Result<bool> {
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
let Some(index) = entries
.iter()
.position(|candidate| entry_id(candidate) == entry_id(expected))
else {
return Ok(false);
};
if entries[index] != *expected {
return Ok(false);
}
// An in-place retype is a retype: the replacement must own the schema id the held record is stored under.
if schema_identity(&entries[index]) != schema_identity(&replacement) {
return Err(retype_conflict(&entries[index], &replacement));
}
entries[index] = replacement;
entries.sort_by_key(entry_id);
self.write_all_unlocked(&entries)?;
Ok(true)
})
}
/// Inserts one envelope only when its polymorphic type/key identity is
/// absent, under the same cross-process lock used by all single-file writes.
pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result<bool> {
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
if entries
.iter()
.any(|candidate| entry_id(candidate) == entry_id(&entry))
{
return Ok(false);
}
entries.push(entry);
entries.sort_by_key(entry_id);
self.write_all_unlocked(&entries)?;
Ok(true)
})
}
/// Atomically replaces an exact set of existing envelopes and inserts any
/// additional companion envelopes. Every precondition is checked beneath
/// the same cross-process lock as the single snapshot write.
pub fn compare_and_swap_batch(
&self,
expected: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if replacements.is_empty() {
return Err(anyhow!(
"conditional batch requires a non-empty replacement set"
));
}
let expected_ids = unique_batch_ids(expected, "expected")?;
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
if !expected_ids.is_subset(&replacement_ids) {
return Err(anyhow!(
"conditional batch must replace every expected identity"
));
}
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
for expected_entry in expected {
let Some(current) = entries
.iter()
.find(|candidate| entry_id(candidate) == entry_id(expected_entry))
else {
return Ok(false);
};
if current != expected_entry {
return Ok(false);
}
}
for replacement in &replacements {
let identity = entry_id(replacement);
if !expected_ids.contains(&identity)
&& entries
.iter()
.any(|candidate| entry_id(candidate) == identity)
{
return Ok(false);
}
}
entries.retain(|candidate| !expected_ids.contains(&entry_id(candidate)));
entries.extend(replacements);
entries.sort_by_key(entry_id);
self.write_all_unlocked(&entries)?;
Ok(true)
})
}
/// Atomically appends companions only when the complete backing-store
/// snapshot is byte-for-byte unchanged. Unlike identity-scoped CAS, this
/// fences predicate cardinality: a concurrently inserted unknown identity
/// makes the expected snapshot stale.
pub fn append_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
additions: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if additions.is_empty() {
return Err(anyhow!("conditional snapshot append requires additions"));
}
unique_batch_ids(expected_snapshot, "expected snapshot")?;
let addition_ids = unique_batch_ids(&additions, "snapshot additions")?;
self.with_exclusive_lock(|| {
let mut current = self.read_all_unlocked()?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
if current != expected {
return Ok(false);
}
if current
.iter()
.any(|row| addition_ids.contains(&entry_id(row)))
{
return Ok(false);
}
current.extend(additions);
current.sort_by_key(entry_id);
self.write_all_unlocked(&current)?;
Ok(true)
})
}
/// Atomically deletes an exact set of envelopes. Any changed or missing
/// member refuses the entire deletion without a partial write.
pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result<bool> {
if expected.is_empty() {
return Err(anyhow!(
"conditional delete requires a non-empty expected set"
));
}
let expected_ids = unique_batch_ids(expected, "delete expected")?;
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
for expected_entry in expected {
let Some(current) = entries
.iter()
.find(|candidate| entry_id(candidate) == entry_id(expected_entry))
else {
return Ok(false);
};
if current != expected_entry {
return Ok(false);
}
}
entries.retain(|candidate| !expected_ids.contains(&entry_id(candidate)));
self.write_all_unlocked(&entries)?;
Ok(true)
})
}
/// Atomically replaces existing rows and appends companion rows only while
/// the complete backing-store snapshot remains byte-exact.
pub fn replace_and_append_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if replacements.is_empty() {
return Err(anyhow!(
"conditional snapshot replacement requires replacements"
));
}
unique_batch_ids(expected_snapshot, "expected snapshot")?;
let replacement_ids = unique_batch_ids(&replacements, "snapshot replacements")?;
self.with_exclusive_lock(|| {
let mut current = self.read_all_unlocked()?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
if current != expected {
return Ok(false);
}
current.retain(|row| !replacement_ids.contains(&entry_id(row)));
current.extend(replacements);
current.sort_by_key(entry_id);
self.write_all_unlocked(&current)?;
Ok(true)
})
}
/// Atomically replaces and deletes rows only while the complete store
/// snapshot remains byte-exact. This is the single-file compaction
/// primitive: summary publication and history deletion share one write.
pub fn replace_and_delete_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
deletions: &[CultCacheEnvelope],
) -> Result<bool> {
if replacements.is_empty() || deletions.is_empty() {
return Err(anyhow!(
"conditional compaction requires replacements and deletions"
));
}
let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?;
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
let deletion_ids = unique_batch_ids(deletions, "deletion")?;
if !deletion_ids.is_subset(&expected_ids) {
return Err(anyhow!(
"compaction deletion is absent from expected snapshot"
));
}
if !deletion_ids.is_disjoint(&replacement_ids) {
return Err(anyhow!("compaction cannot replace and delete one identity"));
}
self.with_exclusive_lock(|| {
let mut current = self.read_all_unlocked()?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
if current != expected {
return Ok(false);
}
for row in &replacements {
if !expected_ids.contains(&entry_id(row))
&& current
.iter()
.any(|candidate| entry_id(candidate) == entry_id(row))
{
return Ok(false);
}
}
current.retain(|row| !deletion_ids.contains(&entry_id(row)));
for row in replacements {
current.retain(|candidate| entry_id(candidate) != entry_id(&row));
current.push(row);
}
current.sort_by_key(entry_id);
self.write_all_unlocked(&current)?;
Ok(true)
})
}
fn read_all_unlocked(&self) -> Result<Vec<CultCacheEnvelope>> {
Ok(self.read_store_unlocked()?.envelopes)
}
/// The one reader of the store file: open, every rewrite and the read-only snapshot ask it, so they agree on which
/// files are stores. Only a file that is not there is an empty store. A file that is there and empty is not a store
/// (no writer leaves one), and any other failure to reach the file, such as a symlink loop, a file where a directory
/// should be, or no permission, stays an io::Error: reading it as empty would let a writer replace a store it never
/// saw. A dangling symbolic link is something at the path, not nothing, for the same reason. Anything else must decode
/// completely, as exactly one store or one legacy envelope array; bytes this runtime cannot read are refused with
/// [`CultCacheStoreUnreadable`], so a rewrite never overwrites what it cannot see.
fn read_store_unlocked(&self) -> Result<DiskStore> {
let bytes = match fs::read(&self.path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound && is_absent(&self.path)? => {
return Ok(DiskStore::absent());
}
Err(error) => {
return Err(anyhow::Error::new(error)
.context(format!("failed to read {}", self.path.display())));
}
};
let head = store_header(&bytes);
self.refuse_unread_format(head.first.as_deref())?;
head.whole
.and_then(|()| decode_store_file(&bytes, head.first.as_deref()))
.map_err(|error| self.unreadable(CultCacheStoreUnreadableKind::Undecodable, error))
}
// The one owner of the stamp rule, asked only by the one reader: a cultcache.store.* header this runtime does not read
// (it reads v1 and v3) is refused by name, judged from the header alone, so a newer store cut short is still a newer
// store. Every write over a file asks that reader first, so a whole-store write judges a stamp as a read does.
fn refuse_unread_format(&self, header: Option<&str>) -> Result<()> {
match header {
Some(header) if header.starts_with("cultcache.store.") && readable_store_format(header).is_none() => Err(self.unreadable(
CultCacheStoreUnreadableKind::UnsupportedFormat,
anyhow!(
"its header is {}; this runtime reads {STORE_FORMAT_V1:?} and {STORE_FORMAT_ELEMENT_IDS:?} only",
describe_header(header)
),
)),
_ => Ok(()),
}
}
fn unreadable(&self, kind: CultCacheStoreUnreadableKind, detail: anyhow::Error) -> anyhow::Error {
// The detail is rebuilt as text that names no value read from the bytes. The
// decoder reports a short read as an io::Error, and dropping it keeps an
// io::Error in the chain meaning only that the file itself could not be read.
anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable {
path: self.path.clone(),
kind,
})
}
/// Reads one filesystem snapshot without creating or opening the sibling
/// CultCache lock file.
///
/// This is deliberately narrower than `pull_all`: use it only at a
/// provider-owned, read-only crossing where the producer replaces the
/// complete file atomically and the consumer has no authority to mutate
/// the provider directory. An absent file is an empty snapshot. Writers
/// and locally owned stores must continue to use the locked APIs.
pub fn pull_all_read_only_snapshot(&self) -> Result<Vec<CultCacheEnvelope>> {
self.read_all_unlocked()
}
fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> {
let failed = |kind| CultCacheStoreWriteFailed {
path: self.path.clone(),
kind,
};
// The header a rewrite writes is the one the file on disk carries: a file marked for element ids stays marked (the
// payloads this store passes through may hold ids), one that is gone or legacy is written v1. The file is read by
// the same reader as an open, so a store this runtime cannot read completely, a variant included, is refused
// and never overwritten.
let disk = match self.read_store_unlocked() {
// A dangling link holds no store, so a whole-file replace has no header to keep and replaces the link (R3 pins
// this). The only NotFound the reader returns is that one: a merging writer has already refused it when it
// read the current entries.
Err(error) if io_not_found(&error) => DiskStore::absent(),
other => other?,
};
let entries = settle_one_record_per_key(entries, &disk)?;
let bytes = encode_store_snapshot(&entries, &disk)
.and_then(|snapshot| rmp_serde::to_vec(&snapshot).context("failed to encode MessagePack"))
.map_err(|error| error.context(failed(CultCacheStoreWriteFailedKind::Rejected)))?;
self.replace_unlocked(&bytes)
.map_err(|error| error.context(failed(CultCacheStoreWriteFailedKind::NotReplaced)))?;
sync_parent_directory(&self.path)
.map_err(|error| error.context(failed(CultCacheStoreWriteFailedKind::ReplacedNotDurable)))
}
/// Everything up to and including the rename over the store file. On failure the
/// store file is as it was.
fn replace_unlocked(&self, bytes: &[u8]) -> Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
remove_abandoned_staging_files(&self.path)?;
let tmp_path = temporary_path_for(&self.path);
let staged = OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)
.with_context(|| format!("failed to create {}", tmp_path.display()))?;
// From here the staging file is this write's to remove: a write that fails
// for lack of space must not keep holding the space it failed on.
if let Err(error) = stage_and_replace(staged, &tmp_path, bytes, &self.path) {
let _ = fs::remove_file(&tmp_path);
return Err(error);
}
Ok(())
}
fn with_shared_lock<T>(&self, action: impl FnOnce() -> Result<T>) -> Result<T> {
let lock = self.open_lock_file()?;
fs2::FileExt::lock_shared(&lock)
.with_context(|| format!("failed to lock {}", self.lock_path().display()))?;
release_lock(lock, action())
}
fn with_exclusive_lock<T>(&self, action: impl FnOnce() -> Result<T>) -> Result<T> {
let lock = self.open_lock_file()?;
fs2::FileExt::lock_exclusive(&lock)
.with_context(|| format!("failed to lock {}", self.lock_path().display()))?;
release_lock(lock, action())
}
/// Replaces the named entries only if each still matches its expectation.
/// Unlike the snapshot form this constrains only the identities named, so
/// unrelated concurrent writes do not lose the race.
pub fn compare_exchange(
&self,
expected: &[CultCacheExpectedEnvelope],
replacements: &[CultCacheEnvelope],
) -> Result<bool> {
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
for condition in expected {
let current = entries
.iter()
.find(|entry| entry.r#type == condition.r#type && entry.key == condition.key);
if current != condition.current.as_ref() {
return Ok(false);
}
if let Some(envelope) = condition.current.as_ref()
&& (envelope.r#type != condition.r#type || envelope.key != condition.key)
{
return Err(anyhow!(
"CultCache compare-exchange expectation identity differs from its envelope"
));
}
}
let mut replacement_ids = BTreeSet::new();
for replacement in replacements {
if replacement.key.trim().is_empty() || replacement.r#type.trim().is_empty() {
return Err(anyhow!(
"CultCache compare-exchange replacement identity is empty"
));
}
if !replacement_ids.insert(entry_id(replacement)) {
return Err(anyhow!(
"CultCache compare-exchange contains a duplicate replacement"
));
}
}
entries.retain(|entry| !replacement_ids.contains(&entry_id(entry)));
entries.extend_from_slice(replacements);
entries.sort_by_key(entry_id);
self.write_all_unlocked(&entries)?;
Ok(true)
})
}
/// Replaces the whole snapshot only if it still equals `expected`, waiting
/// for the exclusive lock. Returns false when another writer has moved the
/// snapshot on, which is a lost race rather than an error.
pub fn compare_exchange_snapshot(
&self,
expected: &[CultCacheEnvelope],
replacements: &[CultCacheEnvelope],
) -> Result<bool> {
self.with_exclusive_lock(|| self.compare_exchange_snapshot_unlocked(expected, replacements))
}
/// Makes one nonblocking attempt at the same exchange. Once the lock is
/// held, comparison, validation, ordering and persistence are identical to
/// [`Self::compare_exchange_snapshot`].
pub fn try_compare_exchange_snapshot(
&self,
expected: &[CultCacheEnvelope],
replacements: &[CultCacheEnvelope],
) -> Result<TryCompareExchangeSnapshotOutcome> {
let Some(exchanged) = self.try_with_exclusive_lock(|| {
self.compare_exchange_snapshot_unlocked(expected, replacements)
})?
else {
return Ok(TryCompareExchangeSnapshotOutcome::LockContended);
};
Ok(if exchanged {
TryCompareExchangeSnapshotOutcome::Exchanged
} else {
TryCompareExchangeSnapshotOutcome::Mismatch
})
}
fn compare_exchange_snapshot_unlocked(
&self,
expected: &[CultCacheEnvelope],
replacements: &[CultCacheEnvelope],
) -> Result<bool> {
let current = self.read_all_unlocked()?;
if current != expected {
return Ok(false);
}
let mut replacement_ids = BTreeSet::new();
for replacement in replacements {
if replacement.key.trim().is_empty() || replacement.r#type.trim().is_empty() {
return Err(anyhow!("CultCache snapshot replacement identity is empty"));
}
if !replacement_ids.insert(entry_id(replacement)) {
return Err(anyhow!(
"CultCache snapshot replacement contains a duplicate identity"
));
}
}
let mut ordered = replacements.to_vec();
ordered.sort_by_key(entry_id);
self.write_all_unlocked(&ordered)?;
Ok(true)
}
fn try_with_exclusive_lock<T>(&self, action: impl FnOnce() -> Result<T>) -> Result<Option<T>> {
let lock = self.open_lock_file()?;
match fs2::FileExt::try_lock_exclusive(&lock) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
Err(error) => {
return Err(error)
.with_context(|| format!("failed to lock {}", self.lock_path().display()));
}
}
release_lock(lock, action()).map(Some)
}
fn open_lock_file(&self) -> Result<File> {
let lock_path = self.lock_path();
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
OpenOptions::new()
.create(true)
.read(true)
.truncate(false)
.write(true)
.open(&lock_path)
.with_context(|| format!("failed to open {}", lock_path.display()))
}
fn lock_path(&self) -> PathBuf {
let mut lock_name = self
.path
.file_name()
.map(|value| value.to_os_string())
.unwrap_or_else(|| "cultcache.cc".into());
lock_name.push(".lock");
self.path.with_file_name(lock_name)
}
}
struct ReadOnlySharedLockGuard<U, D>
where
U: FnOnce(&File) -> Result<()>,
D: FnOnce(anyhow::Error),
{
lock: Option<File>,
unlock: Option<U>,
on_unlock_failure: Option<D>,
}
impl<U, D> ReadOnlySharedLockGuard<U, D>
where
U: FnOnce(&File) -> Result<()>,
D: FnOnce(anyhow::Error),
{
fn new(lock: File, unlock: U, on_unlock_failure: D) -> Self {
Self {
lock: Some(lock),
unlock: Some(unlock),
on_unlock_failure: Some(on_unlock_failure),
}
}
}
impl<U, D> Drop for ReadOnlySharedLockGuard<U, D>
where
U: FnOnce(&File) -> Result<()>,
D: FnOnce(anyhow::Error),
{
fn drop(&mut self) {
let (Some(lock), Some(unlock)) = (self.lock.as_ref(), self.unlock.take()) else {
return;
};
if let Err(error) = unlock(lock)
&& let Some(on_unlock_failure) = self.on_unlock_failure.take()
{
on_unlock_failure(error);
}
}
}
fn unique_batch_ids(
entries: &[CultCacheEnvelope],
label: &str,
) -> Result<BTreeSet<(String, String)>> {
let ids = entries.iter().map(entry_id).collect::<BTreeSet<_>>();
if ids.len() != entries.len() {
return Err(anyhow!(
"conditional batch {label} set contains duplicate identities"
));
}
Ok(ids)
}
impl CacheBackingStore for SingleFileMessagePackBackingStore {
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>> {
if is_absent(&self.path)? && is_absent(&self.lock_path())? {
return Ok(Vec::new());
}
self.with_shared_lock(|| self.read_all_unlocked())
}
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
// A write replaces the record of its own type under its key; a second type under the key reaches the commit guard.
entries.retain(|candidate| entry_id(candidate) != entry_id(entry));
entries.push(entry.clone());
entries.sort_by_key(entry_id);
self.write_all_unlocked(&entries)
})
}
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
self.with_exclusive_lock(|| {
let mut entries = self.read_all_unlocked()?;
entries.retain(|candidate| entry_id(candidate) != entry_id(entry));
self.write_all_unlocked(&entries)
})
}
fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()> {
self.with_exclusive_lock(|| {
// A soft flush asks the one reader: nothing at the path is written, a store it reads is left alone, and anything
// the reader refuses (an unreadable file, a dangling link) is refused here as it is for open.
if options.soft && !self.read_store_unlocked()?.absent {
return Ok(());
}
// Of entries sharing a key and a schema id the last one given is the record, whatever their type names (a type
// renamed under a stable id); a second schema id under a key reaches the commit guard.
let mut by_id = BTreeMap::<(String, String), &CultCacheEnvelope>::new();
for entry in entries {
by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), entry);
}
let mut entries: Vec<CultCacheEnvelope> = by_id.into_values().cloned().collect();
entries.sort_by_key(entry_id);
self.write_all_unlocked(&entries)
})
}
}
const REDB_ENVELOPES: TableDefinition<&[u8], &[u8]> = TableDefinition::new("cultcache_envelopes");
/// Transactional keyed CultCache storage. Each polymorphic `(type, key)`
/// identity is an independent redb row whose value is the exact MessagePack
/// serialization of its `CultCacheEnvelope`.
#[derive(Clone)]
pub struct RedbMessagePackBackingStore {
path: PathBuf,
}
impl std::fmt::Debug for RedbMessagePackBackingStore {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RedbMessagePackBackingStore")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl RedbMessagePackBackingStore {
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let store = Self { path };
store.with_database(|database| {
let write = database.begin_write()?;
{
write.open_table(REDB_ENVELOPES)?;
}
write.commit()?;
Ok(())
})?;
Ok(store)
}
pub fn path(&self) -> &Path {
&self.path
}
fn open_database(&self) -> Result<Database> {
Database::create(&self.path)
.with_context(|| format!("failed to open redb CultCache {}", self.path.display()))
}
/// redb permits one open writable database handle per path. The external
/// CultCache lock therefore owns the complete open/transaction/close
/// interval, preserving the fresh-handle and cross-process construction
/// model used by the other backing store.
fn with_database<T>(&self, action: impl FnOnce(&Database) -> Result<T>) -> Result<T> {
let lock_path = self.lock_path();
if let Some(parent) = lock_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let lock = OpenOptions::new()
.create(true)
.read(true)
.truncate(false)
.write(true)
.open(&lock_path)
.with_context(|| format!("failed to open {}", lock_path.display()))?;
fs2::FileExt::lock_exclusive(&lock)
.with_context(|| format!("failed to lock {}", lock_path.display()))?;
release_lock(lock, self.open_database().and_then(|database| action(&database)))
}
fn lock_path(&self) -> PathBuf {
let mut lock_name = self
.path
.file_name()
.map(|value| value.to_os_string())
.unwrap_or_else(|| "cultcache.cc".into());
lock_name.push(".lock");
self.path.with_file_name(lock_name)
}
pub fn compare_and_swap_entry(
&self,
expected: &CultCacheEnvelope,
replacement: CultCacheEnvelope,
) -> Result<bool> {
if entry_id(expected) != entry_id(&replacement) {
return Err(anyhow!("entry CAS replacement must preserve identity"));
}
self.with_database(|database| {
let write = database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
if read_redb_entry(&table, expected)? != Some(expected.clone()) {
false
} else {
insert_redb_entry(&mut table, &replacement)?;
true
}
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
})
}
pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result<bool> {
self.with_database(|database| {
let write = database.begin_write()?;
let inserted = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let key = redb_identity(&entry)?;
if table.get(key.as_slice())?.is_some() {
false
} else {
insert_redb_entry(&mut table, &entry)?;
true
}
};
if inserted {
write.commit()?;
} else {
write.abort()?;
}
Ok(inserted)
})
}
pub fn compare_and_swap_batch(
&self,
expected: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if replacements.is_empty() {
return Err(anyhow!(
"conditional batch requires a non-empty replacement set"
));
}
let expected_ids = unique_batch_ids(expected, "expected")?;
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
if !expected_ids.is_subset(&replacement_ids) {
return Err(anyhow!(
"conditional batch must replace every expected identity"
));
}
self.with_database(|database| {
let write = database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut valid = true;
for row in expected {
if read_redb_entry(&table, row)? != Some(row.clone()) {
valid = false;
break;
}
}
if valid {
for row in &replacements {
if !expected_ids.contains(&entry_id(row))
&& read_redb_entry(&table, row)?.is_some()
{
valid = false;
break;
}
}
}
if valid {
for row in &replacements {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
})
}
pub fn append_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
additions: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if additions.is_empty() {
return Err(anyhow!("conditional snapshot append requires additions"));
}
unique_batch_ids(expected_snapshot, "expected snapshot")?;
let addition_ids = unique_batch_ids(&additions, "snapshot additions")?;
self.with_database(|database| {
let write = database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut current = read_all_redb(&table)?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
let valid = current == expected
&& !current
.iter()
.any(|row| addition_ids.contains(&entry_id(row)));
if valid {
for row in &additions {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
})
}
pub fn replace_and_append_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if replacements.is_empty() {
return Err(anyhow!(
"conditional snapshot replacement requires replacements"
));
}
unique_batch_ids(expected_snapshot, "expected snapshot")?;
unique_batch_ids(&replacements, "snapshot replacements")?;
self.with_database(|database| {
let write = database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut current = read_all_redb(&table)?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
let valid = current == expected;
if valid {
for row in &replacements {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
})
}
/// Atomically replaces and deletes rows only while the complete store
/// snapshot remains byte-exact. This is the compaction primitive for a
/// non-owning keyed handle: a stale reader cannot summarize one history
/// while deleting another.
pub fn replace_and_delete_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
deletions: &[CultCacheEnvelope],
) -> Result<bool> {
if replacements.is_empty() || deletions.is_empty() {
return Err(anyhow!(
"conditional compaction requires replacements and deletions"
));
}
let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?;
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
let deletion_ids = unique_batch_ids(deletions, "deletion")?;
if !deletion_ids.is_subset(&expected_ids) {
return Err(anyhow!(
"compaction deletion is absent from expected snapshot"
));
}
if !deletion_ids.is_disjoint(&replacement_ids) {
return Err(anyhow!("compaction cannot replace and delete one identity"));
}
self.with_database(|database| {
let write = database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut current = read_all_redb(&table)?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
let mut valid = current == expected;
if valid {
for row in &replacements {
if !expected_ids.contains(&entry_id(row))
&& read_redb_entry(&table, row)?.is_some()
{
valid = false;
break;
}
}
}
if valid {
for identity in deletion_ids {
let key = rmp_serde::to_vec(&identity)
.context("failed to encode CultCache identity")?;
table.remove(key.as_slice())?;
}
for row in &replacements {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
})
}
pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result<bool> {
if expected.is_empty() {
return Err(anyhow!(
"conditional delete requires a non-empty expected set"
));
}
unique_batch_ids(expected, "delete expected")?;
self.with_database(|database| {
let write = database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut valid = true;
for row in expected {
if read_redb_entry(&table, row)? != Some(row.clone()) {
valid = false;
break;
}
}
if valid {
for row in expected {
let key = redb_identity(row)?;
table.remove(key.as_slice())?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
})
}
}
struct OwnedRedbInner {
database: Database,
_owner_lock: File,
file_identity: String,
}
/// A pinned redb store for one long-lived service owner. Unlike the transient
/// redb store, this handle holds the CultCache external lock, an open file
/// handle, and the redb database for its entire lifetime. Clones share that
/// exact ownership authority.
#[derive(Clone)]
pub struct OwnedRedbMessagePackBackingStore {
path: PathBuf,
inner: Arc<OwnedRedbInner>,
}
impl std::fmt::Debug for OwnedRedbMessagePackBackingStore {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("OwnedRedbMessagePackBackingStore")
.field("path", &self.path)
.field("file_identity", &self.inner.file_identity)
.finish_non_exhaustive()
}
}
impl OwnedRedbMessagePackBackingStore {
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let lock_path = redb_lock_path(&path);
let owner_lock = OpenOptions::new()
.create(true)
.read(true)
.truncate(false)
.write(true)
.open(&lock_path)
.with_context(|| format!("failed to open {}", lock_path.display()))?;
fs2::FileExt::try_lock_exclusive(&owner_lock).with_context(|| {
format!(
"redb CultCache {} already has an active owner",
path.display()
)
})?;
// Give redb ownership of the already pinned file, structurally closing
// path substitution during database creation. The post-open identity
// comparison separately verifies that the pathname still names it.
let database_file = OpenOptions::new()
.create(true)
.read(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("failed to pin redb CultCache {}", path.display()))?;
let held_identity = file_identity_from_file(&database_file)?;
let database = Database::builder()
.create_file(database_file)
.with_context(|| format!("failed to open redb CultCache {}", path.display()))?;
let path_identity_file = File::open(&path)?;
let path_identity = file_identity_from_file(&path_identity_file)?;
if held_identity != path_identity {
bail!(
"redb CultCache path identity changed while opening: held {held_identity}, path {path_identity}"
);
}
let write = database.begin_write()?;
{
write.open_table(REDB_ENVELOPES)?;
}
write.commit()?;
Ok(Self {
path,
inner: Arc::new(OwnedRedbInner {
database,
_owner_lock: owner_lock,
file_identity: held_identity,
}),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn file_identity(&self) -> &str {
&self.inner.file_identity
}
pub fn require_file_identity(&self, expected: &str) -> Result<()> {
if expected != self.file_identity() {
bail!(
"redb CultCache file identity mismatch: expected {expected}, owned {}",
self.file_identity()
);
}
Ok(())
}
pub fn validate_path_identity(&self) -> Result<()> {
let current_file = File::open(&self.path)
.with_context(|| format!("owned redb path {} is missing", self.path.display()))?;
let current = file_identity_from_file(&current_file)?;
self.require_file_identity(&current).with_context(|| {
format!(
"owned redb path {} no longer names the pinned file",
self.path.display()
)
})
}
pub fn compare_and_swap_entry(
&self,
expected: &CultCacheEnvelope,
replacement: CultCacheEnvelope,
) -> Result<bool> {
if entry_id(expected) != entry_id(&replacement) {
return Err(anyhow!("entry CAS replacement must preserve identity"));
}
let write = self.inner.database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
if read_redb_entry(&table, expected)? != Some(expected.clone()) {
false
} else {
insert_redb_entry(&mut table, &replacement)?;
true
}
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
}
pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result<bool> {
let write = self.inner.database.begin_write()?;
let inserted = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let key = redb_identity(&entry)?;
if table.get(key.as_slice())?.is_some() {
false
} else {
insert_redb_entry(&mut table, &entry)?;
true
}
};
if inserted {
write.commit()?;
} else {
write.abort()?;
}
Ok(inserted)
}
pub fn compare_and_swap_batch(
&self,
expected: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if replacements.is_empty() {
return Err(anyhow!(
"conditional batch requires a non-empty replacement set"
));
}
let expected_ids = unique_batch_ids(expected, "expected")?;
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
if !expected_ids.is_subset(&replacement_ids) {
return Err(anyhow!(
"conditional batch must replace every expected identity"
));
}
let write = self.inner.database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut valid = true;
for row in expected {
if read_redb_entry(&table, row)? != Some(row.clone()) {
valid = false;
break;
}
}
if valid {
for row in &replacements {
if !expected_ids.contains(&entry_id(row))
&& read_redb_entry(&table, row)?.is_some()
{
valid = false;
break;
}
}
}
if valid {
for row in &replacements {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
}
pub fn append_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
additions: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if additions.is_empty() {
return Err(anyhow!("conditional snapshot append requires additions"));
}
unique_batch_ids(expected_snapshot, "expected snapshot")?;
let addition_ids = unique_batch_ids(&additions, "snapshot additions")?;
let write = self.inner.database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut current = read_all_redb(&table)?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
let valid = current == expected
&& !current
.iter()
.any(|row| addition_ids.contains(&entry_id(row)));
if valid {
for row in &additions {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
}
pub fn replace_and_append_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
) -> Result<bool> {
if replacements.is_empty() {
return Err(anyhow!(
"conditional snapshot replacement requires replacements"
));
}
unique_batch_ids(expected_snapshot, "expected snapshot")?;
unique_batch_ids(&replacements, "snapshot replacements")?;
let write = self.inner.database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut current = read_all_redb(&table)?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
let valid = current == expected;
if valid {
for row in &replacements {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
}
pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result<bool> {
if expected.is_empty() {
return Err(anyhow!(
"conditional delete requires a non-empty expected set"
));
}
let expected_ids = unique_batch_ids(expected, "delete expected")?;
let write = self.inner.database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut valid = true;
for row in expected {
if read_redb_entry(&table, row)? != Some(row.clone()) {
valid = false;
break;
}
}
if valid {
for identity in expected_ids {
let key = rmp_serde::to_vec(&identity)
.context("failed to encode CultCache identity")?;
table.remove(key.as_slice())?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
}
/// Atomically replaces and deletes rows only while the complete store
/// snapshot remains byte-exact. This is the compaction primitive: a stale
/// reader cannot summarize one history while deleting another.
pub fn replace_and_delete_if_snapshot_unchanged(
&self,
expected_snapshot: &[CultCacheEnvelope],
replacements: Vec<CultCacheEnvelope>,
deletions: &[CultCacheEnvelope],
) -> Result<bool> {
if replacements.is_empty() || deletions.is_empty() {
return Err(anyhow!(
"conditional compaction requires replacements and deletions"
));
}
let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?;
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
let deletion_ids = unique_batch_ids(deletions, "deletion")?;
if !deletion_ids.is_subset(&expected_ids) {
return Err(anyhow!(
"compaction deletion is absent from expected snapshot"
));
}
if !deletion_ids.is_disjoint(&replacement_ids) {
return Err(anyhow!("compaction cannot replace and delete one identity"));
}
let write = self.inner.database.begin_write()?;
let matched = {
let mut table = write.open_table(REDB_ENVELOPES)?;
let mut current = read_all_redb(&table)?;
let mut expected = expected_snapshot.to_vec();
current.sort_by_key(entry_id);
expected.sort_by_key(entry_id);
let mut valid = current == expected;
if valid {
for row in &replacements {
if !expected_ids.contains(&entry_id(row))
&& read_redb_entry(&table, row)?.is_some()
{
valid = false;
break;
}
}
}
if valid {
for identity in deletion_ids {
let key = rmp_serde::to_vec(&identity)
.context("failed to encode CultCache identity")?;
table.remove(key.as_slice())?;
}
for row in &replacements {
insert_redb_entry(&mut table, row)?;
}
}
valid
};
if matched {
write.commit()?;
} else {
write.abort()?;
}
Ok(matched)
}
}
impl CacheBackingStore for OwnedRedbMessagePackBackingStore {
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>> {
let read = self.inner.database.begin_read()?;
let table = read.open_table(REDB_ENVELOPES)?;
read_all_redb(&table)
}
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
let write = self.inner.database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
insert_redb_entry(&mut table, entry)?;
}
write.commit()?;
Ok(())
}
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
let write = self.inner.database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
let key = redb_identity(entry)?;
table.remove(key.as_slice())?;
}
write.commit()?;
Ok(())
}
fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> {
unique_batch_ids(entries, "push all")?;
let write = self.inner.database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
let keys = table
.iter()?
.map(|row| Ok(row?.0.value().to_vec()))
.collect::<Result<Vec<_>>>()?;
for key in keys {
table.remove(key.as_slice())?;
}
for entry in entries {
insert_redb_entry(&mut table, entry)?;
}
}
write.commit()?;
Ok(())
}
}
fn redb_lock_path(path: &Path) -> PathBuf {
let mut lock_name = path
.file_name()
.map(|value| value.to_os_string())
.unwrap_or_else(|| "cultcache.cc".into());
lock_name.push(".lock");
path.with_file_name(lock_name)
}
#[cfg(unix)]
fn file_identity_from_file(file: &File) -> Result<String> {
use std::os::unix::fs::MetadataExt;
let metadata = file.metadata()?;
Ok(format!(
"unix:{:016x}:{:016x}",
metadata.dev(),
metadata.ino()
))
}
#[cfg(windows)]
fn file_identity_from_file(file: &File) -> Result<String> {
use std::mem::zeroed;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { zeroed() };
let success =
unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut information) };
if success == 0 {
return Err(std::io::Error::last_os_error())
.context("failed to read Windows file identity");
}
let index = ((information.nFileIndexHigh as u64) << 32) | information.nFileIndexLow as u64;
Ok(format!(
"windows:{:08x}:{index:016x}",
information.dwVolumeSerialNumber
))
}
fn redb_identity(entry: &CultCacheEnvelope) -> Result<Vec<u8>> {
rmp_serde::to_vec(&entry_id(entry)).context("failed to encode CultCache identity")
}
fn read_redb_entry(
table: &impl ReadableTable<&'static [u8], &'static [u8]>,
identity: &CultCacheEnvelope,
) -> Result<Option<CultCacheEnvelope>> {
let key = redb_identity(identity)?;
table
.get(key.as_slice())?
.map(|value| {
decode_grown(value.value())
.map_err(opaque_decode_error)
.context("failed to decode redb CultCache envelope")
})
.transpose()
}
fn insert_redb_entry(
table: &mut redb::Table<&[u8], &[u8]>,
entry: &CultCacheEnvelope,
) -> Result<()> {
let key = redb_identity(entry)?;
let value = rmp_serde::to_vec(entry).context("failed to encode redb CultCache envelope")?;
table.insert(key.as_slice(), value.as_slice())?;
Ok(())
}
fn read_all_redb(
table: &impl ReadableTable<&'static [u8], &'static [u8]>,
) -> Result<Vec<CultCacheEnvelope>> {
let mut entries = table
.iter()?
.map(|row| {
let (_, value) = row?;
decode_grown(value.value())
.map_err(opaque_decode_error)
.context("failed to decode redb CultCache envelope")
})
.collect::<Result<Vec<_>>>()?;
entries.sort_by_key(entry_id);
Ok(entries)
}
impl CacheBackingStore for RedbMessagePackBackingStore {
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>> {
self.with_database(|database| {
let read = database.begin_read()?;
let table = read.open_table(REDB_ENVELOPES)?;
read_all_redb(&table)
})
}
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
self.with_database(|database| {
let write = database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
insert_redb_entry(&mut table, entry)?;
}
write.commit()?;
Ok(())
})
}
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
self.with_database(|database| {
let write = database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
let key = redb_identity(entry)?;
table.remove(key.as_slice())?;
}
write.commit()?;
Ok(())
})
}
fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> {
unique_batch_ids(entries, "push all")?;
self.with_database(|database| {
let write = database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
let keys = table
.iter()?
.map(|row| Ok(row?.0.value().to_vec()))
.collect::<Result<Vec<_>>>()?;
for key in keys {
table.remove(key.as_slice())?;
}
for entry in entries {
insert_redb_entry(&mut table, entry)?;
}
}
write.commit()?;
Ok(())
})
}
}
struct CultCacheStoreRegistration {
store: Box<dyn CacheBackingStore>,
types: BTreeSet<String>,
}
pub struct CultCache {
definitions: BTreeMap<String, Definition>,
/// The registered types declared `#[cultcache(global)]`.
globals: BTreeSet<String>,
schema_name_definitions: BTreeMap<String, String>,
entries: BTreeMap<(String, String), CultCacheEnvelope>,
/// Derived from `entries` by the uniqueness judge: (type, alias, value) -> the one key holding that value. It
/// decides nothing; `get_by_index` reads it.
index_holders: IndexHolders,
/// Per registered type with `UNIQUE_INDEXES`: decodes a payload as that type and returns its non-blank index
/// values. Registration data, so the judge decodes only indexed types.
projectors: BTreeMap<String, IndexProjector>,
stores: Vec<CultCacheStoreRegistration>,
/// Records a store holds that no registered type claims: (store index, the envelope as the store read it). Derived from the last
/// pull and replaced with it; written nowhere else. It decides only refusals at its keys.
foreign: Vec<(usize, CultCacheEnvelope)>,
}
/// A record a store holds that no registered type claims: carried, never served.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ForeignRecord {
pub key: String,
pub schema_id: String,
pub schema_name: String,
pub stored_at: String,
}
type IndexProjector = fn(&[u8]) -> Result<Vec<(&'static str, String)>>;
/// Orders by UTF-16 code unit (C# `StringComparer.Ordinal`), not by UTF-8 bytes or code point.
fn utf16_cmp(left: &str, right: &str) -> std::cmp::Ordering {
left.encode_utf16().cmp(right.encode_utf16())
}
/// Orders violations (alias, least key, second key, schema) field by field, each by UTF-16.
fn violation_cmp(left: &[&str; 4], right: &[&str; 4]) -> std::cmp::Ordering {
left.iter().zip(right).map(|(a, b)| utf16_cmp(a, b)).find(|order| order.is_ne()).unwrap_or(std::cmp::Ordering::Equal)
}
type IndexSlot = (String, String, String);
type IndexHolders = BTreeMap<IndexSlot, String>;
fn project_indexes<T: DatabaseEntry>(payload: &[u8]) -> Result<Vec<(&'static str, String)>> {
let document = decode_payload::<T>(payload)?;
Ok(T::UNIQUE_INDEXES
.iter()
.filter_map(|(alias, read)| read(&document).map(|value| (*alias, value)))
.filter(|(_, value)| !value.trim().is_empty())
.collect())
}
/// What an admission does to the holder map: slots it releases (still held by the key that held them) and slots it takes.
#[derive(Default)]
struct IndexChange {
released: Vec<(IndexSlot, String)>,
taken: Vec<(IndexSlot, String)>,
}
impl IndexChange {
fn apply(self, holders: &mut IndexHolders) {
for (slot, key) in self.released {
if holders.get(&slot) == Some(&key) {
holders.remove(&slot);
}
}
holders.extend(self.taken);
}
}
impl CultCache {
pub fn new() -> Self {
Self {
definitions: BTreeMap::new(),
globals: BTreeSet::new(),
schema_name_definitions: BTreeMap::new(),
entries: BTreeMap::new(),
index_holders: BTreeMap::new(),
projectors: BTreeMap::new(),
stores: Vec::new(),
foreign: Vec::new(),
}
}
pub fn register_entry_type<T: DatabaseEntry>(&mut self) -> Result<()> {
if T::TYPE.trim().is_empty() {
return Err(anyhow!(
"CultCache entry types must declare a non-empty type"
));
}
if let Some(existing) = self.definitions.get(T::TYPE)
&& existing.schema_name != T::SCHEMA_NAME
{
return Err(anyhow!(
"CultCache already has a different definition registered for type {:?}",
T::TYPE
));
}
if let Some(existing_type) = self.schema_name_definitions.get(T::SCHEMA_NAME)
&& existing_type != T::TYPE
{
return Err(anyhow!(
"CultCache schema name {:?} is already registered for type {:?}",
T::SCHEMA_NAME,
existing_type
));
}
// The declaration decides what the held records hold (index projector, global flag), so a changed declaration
// re-judges them; a held violation (or a record the new type cannot decode) refuses the registration and
// changes nothing.
let previous = self.projectors.get(T::TYPE).copied();
let was_global = self.globals.contains(T::TYPE);
if T::UNIQUE_INDEXES.is_empty() {
self.projectors.remove(T::TYPE);
} else {
self.projectors.insert(T::TYPE.to_string(), project_indexes::<T>);
}
if T::GLOBAL {
self.globals.insert(T::TYPE.to_string());
} else {
self.globals.remove(T::TYPE);
}
let held_type = self.entries.range((T::TYPE.to_string(), String::new())..).next().is_some_and(|((held, _), _)| held == T::TYPE);
if held_type {
match self.refuse_second_globals(&self.entries).and_then(|()| self.judge_image(&self.entries)) {
Ok(holders) => self.index_holders = holders,
Err(refusal) => {
match previous {
Some(project) => self.projectors.insert(T::TYPE.to_string(), project),
None => self.projectors.remove(T::TYPE),
};
if was_global {
self.globals.insert(T::TYPE.to_string());
} else {
self.globals.remove(T::TYPE);
}
return Err(refusal);
}
}
}
self.definitions.insert(T::TYPE.to_string(), Definition { schema_name: T::SCHEMA_NAME, unread_slots: unread_slots_of::<T> });
self.schema_name_definitions
.insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string());
Ok(())
}
pub fn register_document_type<T: DatabaseEntry>(&mut self) -> Result<()> {
self.register_entry_type::<T>()
}
pub fn registered_entry_types(&self) -> Vec<String> {
self.definitions.keys().cloned().collect()
}
pub fn register_registry<R: CultCacheRegistry>(&mut self, registry: R) -> Result<&mut Self> {
registry.register_entries(self)?;
Ok(self)
}
/// Makes the store home to `types`, or the generic store when `types` is
/// empty. A type has exactly one home store: a second generic store or a
/// type already claimed by another store is an error and attaches nothing.
pub fn add_backing_store(
&mut self,
store: impl CacheBackingStore + 'static,
types: impl IntoIterator<Item = impl Into<String>>,
) -> Result<()> {
let types: BTreeSet<String> = types.into_iter().map(Into::into).collect();
if types.is_empty() && self.stores.iter().any(|registration| registration.types.is_empty()) {
return Err(anyhow!(
"Backing store would be a second generic store; name the types it is home to"
));
}
if let Some(claimed) = types
.iter()
.find(|type_id| self.stores.iter().any(|registration| registration.types.contains(*type_id)))
{
return Err(anyhow!(
"CultCache type {:?} is already routed to another backing store; it cannot also route to this one",
claimed
));
}
self.stores.push(CultCacheStoreRegistration {
store: Box::new(store),
types,
});
// A record's home cannot change after it is admitted.
let candidate = self.stores.len() - 1;
let held: BTreeSet<String> = self.entries.keys().map(|(type_id, _)| type_id.clone()).collect();
for type_id in &held {
let type_id = type_id.as_str();
let after = self.resolve_route_index(type_id);
let before = self.stores[..candidate]
.iter()
.position(|registration| registration.types.contains(type_id))
.or_else(|| self.stores[..candidate].iter().position(|registration| registration.types.is_empty()));
if before != after {
let error = anyhow!(
"Attaching this backing store would move {:?} from {} to {}; attach routed stores before the generic store",
type_id,
self.describe_store(before),
self.describe_store(after)
);
self.stores.pop();
return Err(error);
}
}
Ok(())
}
pub fn add_generic_backing_store(&mut self, store: impl CacheBackingStore + 'static) -> Result<()> {
self.add_backing_store(store, Vec::<String>::new())
}
/// Reads every store into a staged image and replaces the cache's view
/// only when all of them loaded, so a refused load admits nothing.
pub fn pull_all_backing_stores(&mut self) -> Result<()> {
let mut loaded = BTreeMap::new();
let mut foreign = Vec::new();
let known_types: BTreeSet<String> = self.definitions.keys().cloned().collect();
let schema_name_definitions = self.schema_name_definitions.clone();
for (index, registration) in self.stores.iter().enumerate() {
for mut entry in registration.store.pull_all()? {
let Some(canonical_type) = resolve_registered_type(
&known_types,
&schema_name_definitions,
entry.schema_id.as_deref(),
&entry.r#type,
) else {
// No registered type claims the record: the cache carries it, never serves it.
self.refuse_retype(&loaded, &foreign, &entry)?;
foreign.push((index, entry));
continue;
};
let home = self.resolve_route_index(&canonical_type);
if home != Some(index) {
return Err(anyhow!(
"CultCache {:?} record {:?} was loaded from {}, but its home is {}",
canonical_type,
entry.key,
self.describe_store(Some(index)),
self.describe_store(home)
));
}
entry.r#type = canonical_type;
self.refuse_retype(&loaded, &foreign, &entry)?;
// Two records of a store resolving to one type at one key (one stamped with the other's id): one record per key.
if let Some(held) = loaded.get(&entry_id(&entry)) {
return Err(retype_conflict(held, &entry));
}
loaded.insert(entry_id(&entry), entry);
}
}
self.refuse_second_globals(&loaded)?;
let holders = self.judge_image(&loaded)?;
self.entries = loaded;
self.foreign = foreign;
self.index_holders = holders;
Ok(())
}
/// The records the last pull found in a store that no registered type claims, sorted by key (UTF-16 order). The cache does not
/// serve them; it carries them through every write and refuses a write or removal at their keys.
pub fn foreign_records(&self) -> Vec<ForeignRecord> {
let mut records: Vec<ForeignRecord> = self
.foreign
.iter()
.map(|(_, entry)| ForeignRecord {
key: entry.key.clone(),
schema_id: schema_identity(entry).to_string(),
schema_name: entry.r#type.clone(),
stored_at: entry.stored_at.clone(),
})
.collect();
records.sort_by(|left, right| utf16_cmp(&left.key, &right.key));
records
}
/// Returns the exact envelopes in the currently loaded cache image.
/// Callers that need optimistic concurrency can validate typed documents
/// and capture CAS expectations from one coherent read.
pub fn snapshot_envelopes(&self) -> Vec<CultCacheEnvelope> {
self.entries.values().cloned().collect()
}
pub fn get<T: DatabaseEntry>(&self, key: &str) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
let Some(entry) = self.entries.get(&entry_id_parts(T::TYPE, key)) else {
return Ok(None);
};
let payload = decode_payload(&entry.payload).with_context(|| {
format!(
"failed to decode CultCache entry {:?} at key {:?} as {}",
T::TYPE,
key,
T::SCHEMA_NAME
)
})?;
Ok(Some(payload))
}
/// 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
/// 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<bool> {
if !matches!(self.held_finding(type_id, key)?, HeldFinding::Free) {
return Ok(false);
}
let probe = CultCacheEnvelope {
key: key.to_string(),
r#type: type_id.to_string(),
payload: Vec::new(),
stored_at: String::new(),
schema_id: None,
};
Ok(self.home_index(type_id).is_ok() && self.refuse_retype(&self.entries, &self.foreign, &probe).is_ok())
}
/// The one decode behind every write entry and [`CultCache::is_writable`]: the held envelope at `(type_id, key)`
/// read under the registered type's mark.
fn held_finding(&self, type_id: &str, key: &str) -> Result<HeldFinding> {
let Some(definition) = self.definitions.get(type_id) else {
bail!("No entry type registered for CultCache envelope type {type_id:?}");
};
let Some(held) = self.entries.get(&entry_id_parts(type_id, key)) else {
return Ok(HeldFinding::Free);
};
Ok(match (definition.unread_slots)(&held.payload) {
Ok(None) => HeldFinding::Free,
Ok(Some(unread)) => HeldFinding::Marked(unread),
Err(error) => HeldFinding::Unreadable(error),
})
}
/// Refuses a write or removal of a record held read-only, before any store is touched: for the slots its payload
/// carries past this build's, or because this build cannot read the payload at all.
fn refuse_unread_slots(&self, type_id: &str, key: &str) -> Result<()> {
match self.held_finding(type_id, key)? {
HeldFinding::Free => Ok(()),
HeldFinding::Unreadable(error) => Err(error.context(format!(
"CultCache entry {type_id:?} at key {key:?} is held read-only: this build cannot read its payload. A newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, not through the cache"
))),
HeldFinding::Marked(unread) => {
let held = &self.entries[&entry_id_parts(type_id, key)];
Err(anyhow::Error::new(SchemaConflictError {
schema_id: schema_identity(held).to_string(),
schema_names: vec![self.definitions[type_id].schema_name.to_string()],
record_key: key.to_string(),
unread_slots: Some(unread),
}))
}
}
}
pub fn get_required<T: DatabaseEntry>(&self, key: &str) -> Result<T> {
self.get::<T>(key)?
.ok_or_else(|| anyhow!("CultCache has no {:?} entry at key {:?}", T::TYPE, key))
}
pub fn get_envelope<T: DatabaseEntry>(&self, key: &str) -> Result<Option<CultCacheEnvelope>> {
self.require_entry_type::<T>()?;
Ok(self.entries.get(&entry_id_parts(T::TYPE, key)).cloned())
}
pub fn get_required_envelope<T: DatabaseEntry>(&self, key: &str) -> Result<CultCacheEnvelope> {
self.get_envelope::<T>(key)?
.ok_or_else(|| anyhow!("CultCache has no {:?} envelope at key {:?}", T::TYPE, key))
}
pub fn get_all<T: DatabaseEntry>(&self) -> Result<Vec<T>> {
self.require_entry_type::<T>()?;
let mut values = Vec::new();
for entry in self.entries.values() {
if entry.r#type != T::TYPE {
continue;
}
values.push(decode_payload(&entry.payload).with_context(|| {
format!(
"failed to decode CultCache entry {:?} at key {:?} as {}",
T::TYPE,
entry.key,
T::SCHEMA_NAME
)
})?);
}
Ok(values)
}
pub fn get_all_with_keys<T: DatabaseEntry>(&self) -> Result<Vec<(String, T)>> {
self.require_entry_type::<T>()?;
let mut values = Vec::new();
for entry in self.entries.values() {
if entry.r#type != T::TYPE {
continue;
}
values.push((
entry.key.clone(),
decode_payload(&entry.payload).with_context(|| {
format!(
"failed to decode CultCache entry {:?} at key {:?} as {}",
T::TYPE,
entry.key,
T::SCHEMA_NAME
)
})?,
));
}
Ok(values)
}
/// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is
/// not a global).
pub fn get_global<T: DatabaseEntry>(&self) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if !T::GLOBAL {
return Ok(None);
}
self.find_record::<T>(|_| true)
}
/// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records
/// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or
/// loaded in; removing it finds the next.
pub fn get_by_name<T: DatabaseEntry>(&self, name: &str) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if name.is_empty() {
return Ok(None);
}
self.find_record::<T>(|document| document.cult_name().as_deref() == Some(name))
}
/// The record whose `#[cultcache(index)]` member `alias` holds `value`: at most one, since an index is unique. A
/// blank value is not indexed, so it finds nothing; neither does an alias `T` does not declare.
pub fn get_by_index<T: DatabaseEntry>(&self, alias: &str, value: &str) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if value.trim().is_empty() || !T::UNIQUE_INDEXES.iter().any(|(declared, _)| *declared == alias) {
return Ok(None);
}
let slot = (T::TYPE.to_string(), alias.to_string(), value.to_string());
match self.index_holders.get(&slot) {
Some(key) => self.get::<T>(key),
None => Ok(None),
}
}
/// The first record of `T`, in key order, that `matches`.
fn find_record<T: DatabaseEntry>(&self, matches: impl Fn(&T) -> bool) -> Result<Option<T>> {
let held = self
.entries
.range((T::TYPE.to_string(), String::new())..)
.take_while(|((held_type, _), _)| held_type == T::TYPE);
for (_, entry) in held {
let document: T = decode_payload(&entry.payload).with_context(|| {
format!(
"failed to decode CultCache entry {:?} at key {:?} as {}",
T::TYPE,
entry.key,
T::SCHEMA_NAME
)
})?;
if matches(&document) {
return Ok(Some(document));
}
}
Ok(None)
}
pub fn soa<T: SoaDocument>(&self) -> Result<CultSoaTable<T>> {
let keyed_rows = self.get_all_with_keys::<T>()?;
let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip();
let columns = T::soa_columns(&documents)
.into_iter()
.map(|(name, values)| (name.to_string(), values))
.collect();
Ok(CultSoaTable {
keys,
documents,
columns,
_document: PhantomData,
})
}
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
let (entry, parsed) = self.prepare_entry(key, value)?;
self.refuse_unread_slots(&entry.r#type, &entry.key)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
self.refuse_retype(&self.entries, &self.foreign, &entry)?;
let change = self.judge_unique(&[&entry])?;
if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.push(&entry)?;
}
self.entries.insert(entry_id(&entry), entry);
change.apply(&mut self.index_holders);
Ok(parsed)
}
pub fn prepare_entry<T: DatabaseEntry>(
&self,
key: impl Into<String>,
value: &T,
) -> Result<(CultCacheEnvelope, T)> {
self.require_entry_type::<T>()?;
let key = key.into();
let payload = rmp_serde::to_vec(value).with_context(|| {
format!(
"failed to encode CultCache entry {:?} at key {:?} as {}",
T::TYPE,
key,
T::SCHEMA_NAME
)
})?;
self.finish_prepared_entry::<T>(key, payload)
}
/// Prepares a typed entry while encoding ordinary nested structs as
/// MessagePack maps. DatabaseEntry itself remains the stable numeric-slot
/// tuple; named nested fields prevent serde's omitted optional fields from
/// shifting later values into the wrong positions.
pub fn prepare_entry_named<T: DatabaseEntry>(
&self,
key: impl Into<String>,
value: &T,
) -> Result<(CultCacheEnvelope, T)> {
self.require_entry_type::<T>()?;
let key = key.into();
let payload = rmp_serde::to_vec_named(value).with_context(|| {
format!(
"failed to encode named CultCache entry {:?} at key {:?} as {}",
T::TYPE,
key,
T::SCHEMA_NAME
)
})?;
self.finish_prepared_entry::<T>(key, payload)
}
fn finish_prepared_entry<T: DatabaseEntry>(
&self,
key: String,
payload: Vec<u8>,
) -> Result<(CultCacheEnvelope, T)> {
let parsed: T = decode_payload(&payload).with_context(|| {
format!(
"failed to validate CultCache entry {:?} at key {:?} as {}",
T::TYPE,
key,
T::SCHEMA_NAME
)
})?;
let entry = CultCacheEnvelope {
key: key.clone(),
r#type: T::TYPE.to_string(),
payload,
stored_at: now_utc_second(),
schema_id: Some(T::TYPE.to_string()),
};
Ok((entry, parsed))
}
pub fn put_prepared_batch(&mut self, staged: Vec<CultCacheEnvelope>) -> Result<()> {
if staged.is_empty() {
return Err(anyhow!("CultCache batch must contain at least one entry"));
}
// Validate every staged record before any store is touched.
let mut store_index = None;
for entry in &staged {
if !self.definitions.contains_key(&entry.r#type) {
return Err(anyhow!(
"CultCache batch entry type {:?} is not registered",
entry.r#type
));
}
if entry.key.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope keys for type {:?} must be non-empty",
entry.r#type
));
}
if entry.stored_at.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope stored_at for type {:?} must be non-empty",
entry.r#type
));
}
require_payload_depth(entry)?;
self.refuse_unread_slots(&entry.r#type, &entry.key)?;
self.refuse_claimed_id(entry)?;
let Some(home) = self.home_index(&entry.r#type)? else {
continue;
};
match store_index {
Some(expected) if expected != home => {
return Err(anyhow!("CultCache atomic batch cannot span backing stores"));
}
None => store_index = Some(home),
_ => {}
}
}
let mut next = self.entries.clone();
for entry in &staged {
next.insert(entry_id(entry), entry.clone());
}
self.refuse_second_globals(&next)?;
for entry in &staged {
self.refuse_retype(&next, &self.foreign, entry)?;
}
let change = self.judge_unique(&staged.iter().collect::<Vec<_>>())?;
if let Some(store_index) = store_index {
// The home store receives its own records only: those it already holds in this cache plus the staged ones, and
// the records it carries that no registered type claims: push_all replaces the store's records, so a foreign
// record is laid back as the store read it.
let snapshot: Vec<_> = next
.values()
.filter(|entry| self.resolve_route_index(&entry.r#type) == Some(store_index))
.chain(self.foreign.iter().filter(|(home, _)| *home == store_index).map(|(_, entry)| entry))
.cloned()
.collect();
self.stores[store_index]
.store
.push_all(&snapshot, PushAllOptions::default())?;
}
self.entries = next;
change.apply(&mut self.index_holders);
Ok(())
}
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
let parsed = self.validate_envelope::<T>(&entry)?;
self.refuse_unread_slots(&entry.r#type, &entry.key)?;
self.refuse_claimed_id(&entry)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
self.refuse_retype(&self.entries, &self.foreign, &entry)?;
let change = self.judge_unique(&[&entry])?;
if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.push(&entry)?;
}
self.entries.insert(entry_id(&entry), entry);
change.apply(&mut self.index_holders);
Ok(parsed)
}
/// Publishes an envelope for a registered dynamic document type without
/// claiming compile-time knowledge of its payload schema. This is the
/// CultMesh/schema-registry boundary: callers own payload validation before
/// admission; CultCache still enforces registered type, identity, timestamp,
/// the payload nesting bound ([`MAX_PAYLOAD_DEPTH`]), routing, and persistence.
pub fn put_raw_envelope(&mut self, entry: CultCacheEnvelope) -> Result<()> {
if !self.definitions.contains_key(&entry.r#type) {
return Err(anyhow!(
"No entry type registered for CultCache envelope type {:?}",
entry.r#type
));
}
if entry.key.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope keys for type {:?} must be non-empty",
entry.r#type
));
}
if entry.stored_at.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope stored_at for type {:?} must be non-empty",
entry.r#type
));
}
require_payload_depth(&entry)?;
self.refuse_unread_slots(&entry.r#type, &entry.key)?;
self.refuse_claimed_id(&entry)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
self.refuse_retype(&self.entries, &self.foreign, &entry)?;
let change = self.judge_unique(&[&entry])?;
if let Some(home) = self.home_index(&entry.r#type)? {
self.stores[home].store.push(&entry)?;
}
self.entries.insert(entry_id(&entry), entry);
change.apply(&mut self.index_holders);
Ok(())
}
fn validate_envelope<T: DatabaseEntry>(&self, entry: &CultCacheEnvelope) -> Result<T> {
self.require_entry_type::<T>()?;
if entry.r#type != T::TYPE {
return Err(anyhow!(
"CultCache envelope type {:?} does not match registered Rust type {:?}",
entry.r#type,
T::TYPE
));
}
if entry.key.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope keys for type {:?} must be non-empty",
T::TYPE
));
}
if entry.stored_at.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope stored_at for type {:?} must be non-empty",
T::TYPE
));
}
decode_payload(&entry.payload).with_context(|| {
format!(
"failed to validate CultCache envelope {:?} at key {:?} as {}",
T::TYPE,
entry.key,
T::SCHEMA_NAME
)
})
}
pub fn update<T, F>(&mut self, key: &str, updater: F) -> Result<T>
where
T: DatabaseEntry,
F: FnOnce(Option<T>) -> T,
{
let current = self.get::<T>(key)?;
self.put::<T>(key.to_string(), &updater(current))
}
pub fn delete<T: DatabaseEntry>(&mut self, key: &str) -> Result<bool> {
self.require_entry_type::<T>()?;
self.refuse_unread_slots(T::TYPE, key)?;
let id = entry_id_parts(T::TYPE, key);
let Some(entry) = self.entries.get(&id).cloned() else {
if let Some((_, foreign)) = self.foreign.iter().find(|(_, foreign)| foreign.key == key) {
return Err(foreign_conflict(foreign, T::TYPE));
}
return Ok(false);
};
let released = self.projected_slots(&entry)?;
if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.delete(&entry)?;
}
self.entries.remove(&id);
IndexChange { released, taken: Vec::new() }.apply(&mut self.index_holders);
Ok(true)
}
pub fn snapshot(&self) -> Vec<CultCacheEnvelope> {
self.entries.values().cloned().collect()
}
pub fn snapshot_soa(&self) -> CultCacheSoaTable {
CultCacheSoaTable::from_envelopes(self.snapshot())
}
/// Replaces the cache's view with the table's records. Every record is
/// checked (registered type, and a home store once any store is attached)
/// into a local image first, so a refused load admits nothing and leaves
/// the prior view intact. Loading writes no store, but a held record that is read-only (unread slots, or a payload
/// this build cannot read) may not be omitted or replaced by the table: the next batch would lay the image over the
/// store.
pub fn load_soa(&mut self, table: CultCacheSoaTable) -> Result<()> {
let mut loaded = BTreeMap::new();
for entry in table.into_envelopes()? {
if !self.definitions.contains_key(&entry.r#type) {
return Err(anyhow!(
"No schema is registered for SoA entry type {:?}",
entry.r#type
));
}
self.home_index(&entry.r#type)?;
self.refuse_claimed_id(&entry)?;
self.refuse_retype(&loaded, &self.foreign, &entry)?;
loaded.insert(entry_id(&entry), entry);
}
// A load is a write of every held key it omits or changes: the one check refuses those held read-only.
for ((type_id, key), held) in &self.entries {
if loaded.get(&(type_id.clone(), key.clone())).is_none_or(|incoming| incoming.payload != held.payload) {
self.refuse_unread_slots(type_id, key)?;
}
}
self.refuse_second_globals(&loaded)?;
let holders = self.judge_image(&loaded)?;
self.entries = loaded;
self.index_holders = holders;
Ok(())
}
/// The slots an envelope holds: its type's non-blank index values, each held by its key.
fn projected_slots(&self, entry: &CultCacheEnvelope) -> Result<Vec<(IndexSlot, String)>> {
let Some(project) = self.projectors.get(&entry.r#type) else {
return Ok(Vec::new());
};
let held = project(&entry.payload).with_context(|| {
format!("failed to project the unique indexes of CultCache entry {:?} at key {:?}", entry.r#type, entry.key)
})?;
Ok(held
.into_iter()
.map(|(alias, value)| ((entry.r#type.clone(), alias.to_string(), value), entry.key.clone()))
.collect())
}
/// The one index rule (C# `RefuseIndexSharing`), judged on the result of an admission: within one type and one
/// unique alias, no two records hold one non-blank value. The holders of a value are the held key (unless this
/// admission rewrites it) plus every landing key projecting it; two distinct holders refuse, naming both keys
/// (ordinal) and the alias, never the value. A key landing twice lands as its last envelope.
fn judge_unique(&self, landing: &[&CultCacheEnvelope]) -> Result<IndexChange> {
self.judge_against(&self.index_holders, landing, true)
}
/// Judges a whole image against an empty holder map and returns the map it installs.
fn judge_image(&self, image: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<IndexHolders> {
let landing: Vec<_> = image.values().collect();
let mut holders = IndexHolders::new();
self.judge_against(&holders, &landing, false)?.apply(&mut holders);
Ok(holders)
}
fn judge_against(&self, held: &IndexHolders, landing: &[&CultCacheEnvelope], replacing: bool) -> Result<IndexChange> {
let mut last: BTreeMap<(&str, &str), &CultCacheEnvelope> = BTreeMap::new();
for entry in landing {
last.insert((entry.r#type.as_str(), entry.key.as_str()), entry);
}
let mut change = IndexChange::default();
let mut groups: Vec<(IndexSlot, BTreeSet<String>)> = Vec::new();
let mut at: BTreeMap<IndexSlot, usize> = BTreeMap::new();
for entry in last.values() {
if !self.projectors.contains_key(&entry.r#type) {
continue;
}
if replacing && let Some(old) = self.entries.get(&entry_id(entry)) {
change.released.extend(self.projected_slots(old)?);
}
for (slot, key) in self.projected_slots(entry)? {
let index = *at.entry(slot.clone()).or_insert_with(|| {
groups.push((slot, BTreeSet::new()));
groups.len() - 1
});
groups[index].1.insert(key);
}
}
let mut least: Option<[&str; 4]> = None;
for (slot, holders) in &mut groups {
if let Some(held_key) = held.get(&*slot)
&& !last.contains_key(&(slot.0.as_str(), held_key.as_str()))
{
holders.insert(held_key.clone());
}
}
let mut ordered: Vec<(&IndexSlot, Vec<&String>)> = Vec::new();
for (slot, holders) in &groups {
let mut keys: Vec<&String> = holders.iter().collect();
keys.sort_by(|left, right| utf16_cmp(left, right));
ordered.push((slot, keys));
}
for (slot, keys) in &ordered {
if let [first, second, ..] = keys.as_slice() {
let schema = self.definitions.get(&slot.0).map_or(slot.0.as_str(), |definition| definition.schema_name);
let violation = [slot.1.as_str(), first.as_str(), second.as_str(), schema];
if least.is_none_or(|best| violation_cmp(&violation, &best).is_lt()) {
least = Some(violation);
}
}
}
if let Some([alias, first, second, schema]) = least {
return Err(anyhow!(
"Records {first} and {second} ({schema}) both hold unique index '{alias}'; an index value names one record."
));
}
for (slot, mut keys) in ordered.into_iter().map(|(slot, keys)| (slot.clone(), keys)) {
let key = keys.remove(0).clone();
change.taken.push((slot, key));
}
Ok(change)
}
fn refuse_second_global(
&self,
entries: &BTreeMap<(String, String), CultCacheEnvelope>,
type_id: &str,
key: &str,
) -> Result<()> {
if !self.globals.contains(type_id) {
return Ok(());
}
let held_other = entries
.range((type_id.to_string(), String::new())..)
.take_while(|((held_type, _), _)| held_type == type_id)
.map(|((_, held_key), _)| held_key)
.find(|held_key| held_key.as_str() != key);
match held_other {
Some(existing_key) => Err(CultCacheSecondGlobal {
schema_name: self.definitions.get(type_id).map_or(type_id, |definition| definition.schema_name).to_string(),
existing_key: existing_key.clone(),
key: key.to_string(),
}
.into()),
None => Ok(()),
}
}
/// The one key-identity judge (C# `RefuseUndeclared` and the cache's held-owned check): a cache holds one record per key
/// whatever its type. `written` may not land at a key a store carries foreign, nor at a key another registered type holds
/// in `image`. C# `WrittenOwns` lets a write replace a held record its own type's id owns; a registered type's id here is
/// its entry type and declares no compatible ids, so another type's record is never owned, whatever schema id either
/// envelope claims. Refused with a typed [`SchemaConflictError`] naming the key and both types, never a value; nothing is
/// changed. The single-file store's commit guard judges the same question for the file on disk (another writer's file)
/// and stays.
fn refuse_retype(
&self,
image: &BTreeMap<(String, String), CultCacheEnvelope>,
foreign: &[(usize, CultCacheEnvelope)],
written: &CultCacheEnvelope,
) -> Result<()> {
if let Some((_, carried)) = foreign.iter().find(|(_, carried)| carried.key == written.key) {
return Err(foreign_conflict(carried, &written.r#type));
}
for type_id in self.definitions.keys().filter(|type_id| **type_id != written.r#type) {
if let Some(held) = image.get(&(type_id.clone(), written.key.clone())) {
return Err(retype_conflict(held, written));
}
}
Ok(())
}
/// An admitted envelope's schema id may not name another registered type: the pull resolves a record's type from that id
/// (`resolve_registered_type`), so admitting it would store one type that every reload serves as the other, at a key this
/// cache may not hold (another writer's record, or a fresh key). Admission asks the pull's own lookup. An id that names no
/// registered type (a dynamic or versioned id) is the envelope's own. Store-level writers called directly trust the id.
fn refuse_claimed_id(&self, entry: &CultCacheEnvelope) -> Result<()> {
let known_types: BTreeSet<String> = self.definitions.keys().cloned().collect();
match resolve_registered_type(&known_types, &self.schema_name_definitions, entry.schema_id.as_deref(), &entry.r#type) {
Some(resolved) if resolved != entry.r#type => Err(anyhow::Error::new(SchemaConflictError {
schema_id: schema_identity(entry).to_string(),
schema_names: vec![resolved, entry.r#type.clone()],
record_key: entry.key.clone(),
unread_slots: None,
})),
_ => Ok(()),
}
}
fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> {
for (type_id, key) in entries.keys() {
self.refuse_second_global(entries, type_id, key)?;
}
Ok(())
}
fn require_entry_type<T: DatabaseEntry>(&self) -> Result<()> {
match self.definitions.get(T::TYPE) {
Some(definition) if definition.schema_name == T::SCHEMA_NAME => Ok(()),
_ => Err(anyhow!(
"CultCache entry type {:?} is not registered on this cache instance",
T::TYPE
)),
}
}
/// The store that claims the type, else the generic store. Registration
/// guarantees at most one of each.
fn resolve_route_index(&self, type_id: &str) -> Option<usize> {
self.stores
.iter()
.position(|registration| registration.types.contains(type_id))
.or_else(|| {
self.stores
.iter()
.position(|registration| registration.types.is_empty())
})
}
/// `None` for a cache with no stores, which is in memory. Once any store
/// is attached, a type with no home store is an error.
fn home_index(&self, type_id: &str) -> Result<Option<usize>> {
if self.stores.is_empty() {
return Ok(None);
}
self.resolve_route_index(type_id)
.map(Some)
.ok_or_else(|| anyhow!("No backing store is registered for entry type {:?}", type_id))
}
fn describe_store(&self, index: Option<usize>) -> String {
match index {
None => "memory".to_string(),
Some(index) if self.stores[index].types.is_empty() => "the generic store".to_string(),
Some(index) => format!(
"the store routed to {:?}",
self.stores[index].types.iter().collect::<Vec<_>>()
),
}
}
}
impl Default for CultCache {
fn default() -> Self {
Self::new()
}
}
fn entry_id(entry: &CultCacheEnvelope) -> (String, String) {
entry_id_parts(&entry.r#type, &entry.key)
}
fn entry_id_parts(r#type: &str, key: &str) -> (String, String) {
(r#type.to_string(), key.to_string())
}
fn now_utc_second() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
/// The catalog names each schema id once. An entry the file already holds is arrived, and its type is its schema's name;
/// one the caller supplies is registered. Records of one tier under one id are one type. Across tiers a write may not
/// retype what the file holds, unless the registered type owns the id (a registered type's id is its entry type): that is
/// a rename under a stable id, and the registered type names the id.
fn encode_store_snapshot(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Result<PersistedStoreSnapshot> {
let arrived: BTreeMap<(String, String), &CultCacheEnvelope> = disk.envelopes.iter().map(|entry| (entry_id(entry), entry)).collect();
// Per schema id and tier: the type its records carry, and the key of the record that named it.
let mut schema_types = BTreeMap::<(String, bool), (String, String)>::new();
for entry in entries {
let schema_id = entry
.schema_id
.clone()
.unwrap_or_else(|| entry.r#type.clone());
let registered = arrived.get(&entry_id(entry)) != Some(&entry);
if let Some((existing_type, _)) = schema_types.insert((schema_id.clone(), registered), (entry.r#type.clone(), entry.key.clone())) {
if existing_type != entry.r#type {
return Err(anyhow::Error::new(SchemaConflictError {
schema_id,
schema_names: vec![existing_type, entry.r#type.clone()],
record_key: entry.key.clone(),
unread_slots: None,
}));
}
}
}
let mut named = BTreeMap::<String, String>::new();
for ((schema_id, registered), (document_type, key)) in schema_types {
if !registered {
named.insert(schema_id, document_type);
} else if let Some(arrived_type) = named.insert(schema_id.clone(), document_type.clone())
&& arrived_type != document_type
&& schema_id != document_type
{
return Err(anyhow::Error::new(SchemaConflictError {
schema_id,
schema_names: vec![arrived_type, document_type],
record_key: key,
unread_slots: None,
}));
}
}
// An entry the file holds for an id is written back as the file holds it: this runtime keeps no description of another
// runtime's schema, so it never replaces one. It describes an id itself only when the file has no entry for it, or when a
// registered type owns the id under another name (a rename, where the registered type names the id).
let owned: BTreeSet<&str> = entries
.iter()
.filter(|entry| entry.schema_id.as_deref().is_none_or(|schema_id| schema_id == entry.r#type))
.map(|entry| entry.r#type.as_str())
.collect();
let mut catalog = BTreeMap::<String, PersistedSchemaCatalogEntry>::new();
for (schema_id, document_type) in named {
let held = disk
.catalog
.iter()
.find(|entry| entry.0 == schema_id)
.or_else(|| disk.catalog.iter().find(|entry| entry.5.contains(&schema_id)))
.filter(|entry| !owned.contains(schema_id.as_str()) || entry.1 == document_type);
let entry = match held {
Some(entry) => entry.clone(),
None => described(&schema_id, document_type),
};
catalog.entry(entry.0.clone()).or_insert(entry);
}
let records = entries
.iter()
.map(|entry| {
PersistedRecord(
entry.key.clone(),
entry
.schema_id
.clone()
.unwrap_or_else(|| entry.r#type.clone()),
entry.stored_at.clone(),
entry.payload.clone(),
)
})
.collect();
Ok(PersistedStoreSnapshot(
disk.format.to_string(),
catalog.into_values().collect(),
records,
))
}
/// The entry this runtime writes for a schema id it describes itself: its entry type is the id and the schema's name.
fn described(schema_id: &str, document_type: String) -> PersistedSchemaCatalogEntry {
PersistedSchemaCatalogEntry(
schema_id.to_string(),
document_type,
format!("{schema_id}.v1"),
schema_id.to_string(),
format!(
"{{\"schemaName\":\"{}\",\"schemaVersion\":\"{}.v1\",\"members\":[]}}",
escape_json_string(schema_id),
escape_json_string(schema_id)
),
vec![schema_id.to_string()],
Vec::new(),
)
}
fn readable_store_format(header: &str) -> Option<&'static str> {
match header {
STORE_FORMAT_V1 => Some(STORE_FORMAT_V1),
STORE_FORMAT_ELEMENT_IDS => Some(STORE_FORMAT_ELEMENT_IDS),
_ => None,
}
}
/// One store file's bytes, decoded: the header it carries (the one a rewrite keeps) and its envelopes. `header` is
/// [`store_header`]'s first slot and the bytes are already proven one complete array. An array whose first slot is not a
/// store header is the legacy envelope array.
fn decode_store_file(bytes: &[u8], header: Option<&str>) -> Result<DiskStore> {
match header {
Some(header) => {
let format = readable_store_format(header)
.ok_or_else(|| anyhow!("the first slot is not a store header this runtime reads"))?;
let (envelopes, catalog) = decode_store_snapshot(bytes)?;
Ok(DiskStore { absent: false, format, envelopes, catalog })
}
None => Ok(DiskStore { absent: false, format: STORE_FORMAT_V1, envelopes: decode_legacy_envelopes(bytes)?, catalog: Vec::new() }),
}
}
/// What a store file holds, as its one reader decoded it: the header a rewrite keeps, its envelopes, and the catalog entries it
/// publishes them by, which a rewrite lays back for the ids they name.
struct DiskStore {
/// Nothing was at the path; the other fields describe an empty v1 store.
absent: bool,
format: &'static str,
envelopes: Vec<CultCacheEnvelope>,
catalog: Vec<PersistedSchemaCatalogEntry>,
}
impl DiskStore {
/// A file that is gone, or a dangling link a whole-file replace is about to replace: an empty v1 store.
fn absent() -> Self {
Self { absent: true, format: STORE_FORMAT_V1, envelopes: Vec::new(), catalog: Vec::new() }
}
}
/// The legacy envelope array: an array of envelope maps. Its acceptance is the one verdict on an array whose first slot
/// is not a store header, for reading it and for replacing it.
fn decode_legacy_envelopes(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
let envelopes: Vec<CultCacheEnvelope> =
decode_grown(bytes).context("failed to decode legacy CultCache envelope array")?;
require_unique_keys(&envelopes)?;
Ok(envelopes)
}
/// A record's identity in a store is its key string alone, compared ordinal: a store holding a key twice is unreadable,
/// whatever the format of the file that holds it.
fn require_unique_keys(envelopes: &[CultCacheEnvelope]) -> Result<()> {
let mut keys = HashSet::<&str>::new();
for envelope in envelopes {
if !keys.insert(envelope.key.as_str()) {
bail!("CultCache record {:?} appears more than once", envelope.key);
}
}
Ok(())
}
/// What a record's stored schema id is: the id it carries, else its type (a registered type's id is its entry type).
fn schema_identity(entry: &CultCacheEnvelope) -> &str {
entry.schema_id.as_deref().unwrap_or(&entry.r#type)
}
/// The refusal of a write at a key a store carries as a record no registered type claims.
fn foreign_conflict(carried: &CultCacheEnvelope, written_type: &str) -> anyhow::Error {
anyhow::Error::new(SchemaConflictError {
schema_id: schema_identity(carried).to_string(),
schema_names: vec![carried.r#type.clone(), written_type.to_string()],
record_key: carried.key.clone(),
unread_slots: None,
})
}
/// The refusal of a write that would retype a key: the written record does not own the schema id the held one is stored under.
/// Retyping is remove-then-add (variants:ruling:cache-refuses-retype).
fn retype_conflict(held: &CultCacheEnvelope, written: &CultCacheEnvelope) -> anyhow::Error {
anyhow::Error::new(SchemaConflictError {
schema_id: schema_identity(written).to_string(),
schema_names: vec![held.r#type.clone(), written.r#type.clone()],
record_key: written.key.clone(),
unread_slots: None,
})
}
/// The commit guard every single-file write passes: a store holds one record per key. Two entries under a key whose schema
/// ids differ are a retype and are refused with the key and both types, nothing written. Two under one schema id are one
/// record: a written entry replaces the held one it owns (a type renamed under a stable id), the held one being what the file
/// already carries.
fn settle_one_record_per_key(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Result<Vec<CultCacheEnvelope>> {
let arrived: BTreeMap<(String, String), &CultCacheEnvelope> = disk.envelopes.iter().map(|entry| (entry_id(entry), entry)).collect();
let held = |entry: &CultCacheEnvelope| arrived.get(&entry_id(entry)) == Some(&entry);
let mut settled = Vec::<&CultCacheEnvelope>::new();
let mut at_key = HashMap::<&str, usize>::new();
for entry in entries {
match at_key.get(entry.key.as_str()) {
None => {
at_key.insert(entry.key.as_str(), settled.len());
settled.push(entry);
}
Some(&slot) if schema_identity(settled[slot]) != schema_identity(entry) => return Err(retype_conflict(settled[slot], entry)),
Some(&slot) => {
if !held(entry) || held(settled[slot]) {
settled[slot] = entry;
}
}
}
}
Ok(settled.into_iter().cloned().collect())
}
/// Decodes a store whose header the caller has already read as a readable one: its envelopes and its catalog.
fn decode_store_snapshot(bytes: &[u8]) -> Result<(Vec<CultCacheEnvelope>, Vec<PersistedSchemaCatalogEntry>)> {
let snapshot: PersistedStoreSnapshot =
decode_grown(bytes).context("failed to decode CultCache v1 snapshot")?;
// A schema id names the entry that has it as its own id; only an id no entry owns names the entry that lists it as compatible.
let mut catalog = BTreeMap::<String, String>::new();
for entry in &snapshot.1 {
catalog.entry(entry.0.clone()).or_insert_with(|| entry.1.clone());
}
for entry in &snapshot.1 {
for compatible in &entry.5 {
catalog.entry(compatible.clone()).or_insert_with(|| entry.1.clone());
}
}
let envelopes = snapshot
.2
.into_iter()
.map(|record| {
let r#type = match catalog.get(&record.1) {
Some(schema_name) => schema_name.clone(),
None => bail!(
"CultCache record {:?} references schema {:?}, which the store's catalog does not publish",
record.0,
record.1
),
};
Ok(CultCacheEnvelope {
key: record.0,
r#type,
stored_at: record.2,
payload: record.3,
schema_id: Some(record.1),
})
})
.collect::<Result<Vec<_>>>()?;
require_unique_keys(&envelopes)?;
Ok((envelopes, snapshot.1))
}
/// A record resolves by its schema id: a registered type's id is its entry type. The name its catalog carries is metadata,
/// and names a type only when the id names none: the registry is keyed by entry type, and a store from a runtime with other
/// schema ids can be read by the type or schema name it carries.
fn resolve_registered_type(
known_types: &BTreeSet<String>,
schema_name_definitions: &BTreeMap<String, String>,
schema_id: Option<&str>,
persisted_type: &str,
) -> Option<String> {
if let Some(schema_id) = schema_id
&& known_types.contains(schema_id)
{
return Some(schema_id.to_string());
}
if known_types.contains(persisted_type) {
return Some(persisted_type.to_string());
}
schema_name_definitions.get(persisted_type).cloned()
}
fn escape_json_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn temporary_path_for(path: &Path) -> PathBuf {
let mut file_name = path
.file_name()
.map(|value| value.to_os_string())
.unwrap_or_else(|| "cultcache.cc".into());
file_name.push(format!(".{}.tmp", uuid::Uuid::new_v4()));
path.with_file_name(file_name)
}
/// Writes and syncs the staged snapshot, closes it, and renames it over `destination`.
fn stage_and_replace(mut staged: File, staged_path: &Path, bytes: &[u8], destination: &Path) -> Result<()> {
use std::io::Write;
staged
.write_all(bytes)
.with_context(|| format!("failed to write {}", staged_path.display()))?;
injected_write_fault(WriteStep::StagingSync)
.and_then(|()| staged.sync_all())
.with_context(|| format!("failed to sync {}", staged_path.display()))?;
drop(staged);
replace_file_atomically(staged_path, destination)
}
/// Releases a lock taken around `result`'s action and returns that result.
///
/// The policy for every lock in this crate: an unlock failure never replaces the action's
/// result, so a write that landed is reported as landed. The failure is discarded here
/// because these paths have no observer; the read-only snapshot, whose API offers one,
/// hands it to [`SingleFileMessagePackBackingStore::with_read_only_shared_snapshot_and_unlock_diagnostic`].
///
/// Dropping `lock` closes this descriptor, which releases the lock only when no other
/// descriptor shares its open file description. A child forked without exec holds such a
/// duplicate, and the lock then stays held until the child closes it or exits.
fn release_lock<T>(lock: File, result: Result<T>) -> Result<T> {
let _unlock_failure_released_by_close =
injected_write_fault(WriteStep::Unlock).and_then(|()| fs2::FileExt::unlock(&lock));
drop(lock);
result
}
fn io_not_found(error: &anyhow::Error) -> bool {
error
.chain()
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound))
}
/// True only when nothing is at `path`; a symbolic link, dangling or not, is something.
/// A path that cannot be inspected (a file where a directory should be, no permission)
/// is an error, never absent.
fn is_absent(path: &Path) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(false),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(error) => Err(anyhow::Error::new(error).context(format!("failed to inspect {}", path.display()))),
}
}
fn remove_abandoned_staging_files(destination: &Path) -> Result<()> {
let Some(parent) = destination.parent() else {
return Ok(());
};
let Some(destination_name) = destination.file_name().and_then(|value| value.to_str()) else {
return Ok(());
};
let prefix = format!("{destination_name}.");
for entry in fs::read_dir(parent).with_context(|| {
format!(
"failed to inspect {} for abandoned staging files",
parent.display()
)
})? {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Some(candidate) = name
.strip_prefix(&prefix)
.and_then(|value| value.strip_suffix(".tmp"))
else {
continue;
};
if uuid::Uuid::parse_str(candidate).is_err() || !entry.file_type()?.is_file() {
continue;
}
fs::remove_file(entry.path()).with_context(|| {
format!(
"failed to remove abandoned staging file {}",
entry.path().display()
)
})?;
}
Ok(())
}
#[cfg(unix)]
fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> {
injected_write_fault(WriteStep::Rename).and_then(|()| fs::rename(staged, destination)).with_context(|| {
format!(
"failed to atomically replace {} with {}",
destination.display(),
staged.display()
)
})
}
#[cfg(windows)]
fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};
injected_write_fault(WriteStep::Rename).context("failed to atomically replace the store")?;
let staged_wide = staged
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let destination_wide = destination
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
// A concurrent reader of the destination (another process opening the
// store, an indexer, an antivirus scan) makes the replace fail with a
// sharing or access error for as long as it holds the handle. That is
// transient by construction; a daemon that treats it as fatal dies on
// the first overlap (Muninn serve did, 2026-09-11). Retry briefly.
const ERROR_ACCESS_DENIED: i32 = 5;
const ERROR_SHARING_VIOLATION: i32 = 32;
const ERROR_LOCK_VIOLATION: i32 = 33;
const ATTEMPTS: u32 = 25;
const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
let mut attempt = 0;
loop {
let success = unsafe {
MoveFileExW(
staged_wide.as_ptr(),
destination_wide.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if success != 0 {
return Ok(());
}
let error = std::io::Error::last_os_error();
attempt += 1;
let transient = matches!(
error.raw_os_error(),
Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
);
if !transient || attempt >= ATTEMPTS {
return Err(error).with_context(|| {
format!(
"failed to atomically replace {} with {} after {attempt} attempt(s)",
destination.display(),
staged.display()
)
});
}
std::thread::sleep(BACKOFF);
}
}
#[cfg(all(test, windows))]
mod windows_replace_tests {
use super::replace_file_atomically;
use std::os::windows::fs::OpenOptionsExt;
/// A reader holding the destination open with no sharing makes the
/// replace fail for as long as it holds on; it must succeed once the
/// reader lets go, not report the first refusal as the outcome.
#[test]
fn a_replace_waits_out_a_reader_that_holds_the_destination() {
let dir = std::env::temp_dir().join(format!("cultcache-replace-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let destination = dir.join("store.cc");
let staged = dir.join("store.cc.tmp");
std::fs::write(&destination, b"old").unwrap();
std::fs::write(&staged, b"new").unwrap();
let held = std::fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&destination)
.unwrap();
let releaser = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(120));
drop(held);
});
replace_file_atomically(&staged, &destination).expect("the replace outlasts the reader");
releaser.join().unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
injected_write_fault(WriteStep::DirectorySync)
.and_then(|()| File::open(parent))
.and_then(|directory| directory.sync_all())
.with_context(|| format!("failed to sync {}", parent.display()))
}
/// The steps no test can make fail for real: fsync and unlock do not fail on a healthy
/// filesystem. Tests arm one step on their own thread; builds outside tests never fail here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)]
enum WriteStep {
StagingSync,
Rename,
DirectorySync,
Unlock,
}
#[cfg(test)]
thread_local! {
static INJECTED_WRITE_FAULT: std::cell::Cell<Option<WriteStep>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
fn injected_write_fault(step: WriteStep) -> std::io::Result<()> {
if INJECTED_WRITE_FAULT.with(|fault| fault.get()) == Some(step) {
return Err(std::io::Error::other("injected write fault"));
}
Ok(())
}
#[cfg(not(test))]
#[inline(always)]
fn injected_write_fault(_step: WriteStep) -> std::io::Result<()> {
Ok(())
}
#[cfg(windows)]
fn sync_parent_directory(_path: &Path) -> Result<()> {
// MoveFileExW with MOVEFILE_WRITE_THROUGH owns publication durability.
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
struct RefusingBatchStore {
inner: SingleFileMessagePackBackingStore,
refuse_batch: Arc<AtomicBool>,
}
impl CacheBackingStore for RefusingBatchStore {
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>> {
self.inner.pull_all()
}
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
self.inner.push(entry)
}
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
self.inner.delete(entry)
}
fn push_all(
&mut self,
entries: &[CultCacheEnvelope],
options: PushAllOptions,
) -> Result<()> {
if self.refuse_batch.load(Ordering::SeqCst) {
return Err(anyhow!("injected batch refusal"));
}
self.inner.push_all(entries, options)
}
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings")]
struct Settings {
#[cultcache(key = 0)]
theme: String,
#[cultcache(key = 1, default)]
retries: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "note")]
struct Note {
#[cultcache(key = 0)]
title: String,
#[cultcache(key = 1)]
body: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "binary-record")]
struct BinaryRecord {
#[cultcache(key = 0, bytes)]
required: Vec<u8>,
#[cultcache(key = 1, bytes, default)]
optional: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct NestedOptionalValue {
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
symbol: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "runtime-policy", schema = "tests.schema_stamped_entry")]
struct SchemaStamped {
#[cultcache(key = 0)]
schema_version: String,
#[cultcache(key = 1)]
name: String,
#[cultcache(key = 2)]
value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "nested-note")]
struct NestedNote {
#[cultcache(key = 0)]
value: NestedOptionalValue,
}
cultcache_soa!(Settings {
"theme" => |row: &Settings| row.theme.clone(),
"retries" => |row: &Settings| row.retries,
});
cultcache_registry!(TestEntries { Settings, Note });
#[test]
fn captured_snapshot_cannot_outlive_a_shared_consequence_gate() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("authority.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let released = CultCacheEnvelope {
key: "brake".into(),
r#type: "brake".into(),
payload: b"released".to_vec(),
stored_at: "2026-07-20T00:00:00Z".into(),
schema_id: Some("brake".into()),
};
store.push(&released)?;
let writer_path = store_path.clone();
let gate_path = store_path.clone();
let gate_released = released.clone();
let (started_tx, started_rx) = mpsc::channel();
let (finished_tx, finished_rx) = mpsc::channel();
store.with_read_only_shared_snapshot(|captured| {
assert_eq!(captured, vec![released.clone()]);
std::thread::spawn(move || {
started_tx.send(()).unwrap();
let engaged = CultCacheEnvelope {
payload: b"engaged".to_vec(),
..released
};
let mut writer = SingleFileMessagePackBackingStore::new(writer_path);
writer.push(&engaged).unwrap();
finished_tx.send(()).unwrap();
});
started_rx.recv_timeout(Duration::from_secs(30))?;
assert!(
finished_rx
.recv_timeout(Duration::from_millis(100))
.is_err()
);
// The writer is still waiting on the gate: the file on disk is untouched.
assert_eq!(
SingleFileMessagePackBackingStore::new(&gate_path).pull_all_read_only_snapshot()?,
vec![gate_released.clone()]
);
Ok(())
})?;
// The writer fsyncs twice once the gate opens; under IO pressure that is slow.
finished_rx.recv_timeout(Duration::from_secs(60))?;
assert_eq!(store.pull_all()?[0].payload, b"engaged");
Ok(())
}
#[test]
fn post_action_unlock_failure_is_diagnostic_not_action_result() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("authority.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
store.push(&CultCacheEnvelope {
key: "brake".into(),
r#type: "brake".into(),
payload: b"released".to_vec(),
stored_at: "2026-07-20T00:00:00Z".into(),
schema_id: Some("brake".into()),
})?;
let mut diagnostic = None;
let result = store.with_read_only_shared_snapshot_using(
|_| Ok(42),
|_| Err(anyhow!("injected post-action unlock failure")),
|error| diagnostic = Some(error.to_string()),
);
assert_eq!(result?, 42);
assert_eq!(
diagnostic.as_deref(),
Some("injected post-action unlock failure")
);
Ok(())
}
#[test]
fn familiar_cultcache_flow_persists_and_reloads_typed_documents() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let settings = Settings {
theme: "ash".to_string(),
retries: 3,
};
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.pull_all_backing_stores()?;
cache.put("app", &settings)?;
assert_eq!(cache.get_required::<Settings>("app")?, settings);
let mut reloaded = CultCache::new();
reloaded.register_entry_type::<Settings>()?;
reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
reloaded.pull_all_backing_stores()?;
assert_eq!(reloaded.get_required::<Settings>("app")?, settings);
Ok(())
}
fn settings_one() -> Settings {
Settings { theme: "green".to_string(), retries: 1 }
}
fn note_one() -> Note {
Note { title: "same key".to_string(), body: "different type".to_string() }
}
fn retype_refusal(error: &anyhow::Error) -> &SchemaConflictError {
error.downcast_ref::<SchemaConflictError>().unwrap_or_else(|| panic!("{error:#}"))
}
fn reloaded(path: &Path) -> Result<Vec<CultCacheEnvelope>> {
SingleFileMessagePackBackingStore::new(path).pull_all()
}
const SECRET: &str = "payload-text-a-refusal-must-not-echo";
fn store_with(path: &Path, records: impl FnOnce(&mut CultCache) -> Result<()>) -> Result<()> {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
records(&mut cache)
}
#[test]
fn retype_memory_cache_refuses_a_second_type_on_every_write_path_and_keeps_the_first() -> Result<()> {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.put("shared", &settings_one())?;
let before = cache.snapshot();
let secret = Note { title: SECRET.to_string(), body: SECRET.to_string() };
let (staged, _) = cache.prepare_entry("shared", &secret)?;
let mut errors = vec![
cache.put("shared", &secret).unwrap_err(),
cache.put_envelope::<Note>(staged.clone()).unwrap_err(),
cache.put_raw_envelope(staged.clone()).unwrap_err(),
cache.put_prepared_batch(vec![staged.clone()]).unwrap_err(),
];
// Two types staged at one key in one batch never land, even at a fresh key.
let (fresh_note, _) = cache.prepare_entry("fresh", &secret)?;
let (fresh_settings, _) = cache.prepare_entry("fresh", &settings_one())?;
errors.push(cache.put_prepared_batch(vec![fresh_note, fresh_settings]).unwrap_err());
for (error, key) in errors.iter().zip(["shared", "shared", "shared", "shared", "fresh"]) {
assert_eq!(retype_refusal(error).record_key, key);
assert!(!format!("{error:#}").contains(SECRET), "a refusal never echoes a value: {error:#}");
}
assert_eq!(cache.snapshot(), before);
cache.delete::<Settings>("shared")?;
cache.put("shared", &secret)?;
Ok(())
}
/// Two types whose envelopes carry one schema id (an id naming no registered type, so admission passes it) are still two
/// types: ownership is the written type's own id, never the id an envelope claims (C# `WrittenOwns` reads the written
/// entry's catalog entry). Refused at a key the other type holds on every envelope path, with and without a store, and in
/// an SoA image.
#[test]
fn retype_envelopes_sharing_one_claimed_id_are_still_two_types() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.cc");
for stored in [false, true] {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
if stored {
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
}
let (mut settings, _) = cache.prepare_entry("shared", &settings_one())?;
settings.schema_id = Some("dynamic.shared.v1".to_string());
cache.put_raw_envelope(settings.clone())?;
let before = (cache.snapshot(), if stored { fs::read(&path)? } else { Vec::new() });
let (mut stamped, _) = cache.prepare_entry("shared", &note_one())?;
stamped.schema_id = settings.schema_id.clone();
for error in [
cache.put_envelope::<Note>(stamped.clone()).unwrap_err(),
cache.put_raw_envelope(stamped.clone()).unwrap_err(),
cache.put_prepared_batch(vec![stamped.clone()]).unwrap_err(),
] {
assert_eq!(retype_refusal(&error).record_key, "shared", "stored {stored}");
}
assert_eq!((cache.snapshot(), if stored { fs::read(&path)? } else { Vec::new() }), before, "stored {stored}");
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![settings, stamped])).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "shared");
assert_eq!(cache.snapshot(), before.0);
}
Ok(())
}
/// An envelope whose schema id names another registered type is refused at admission, whether or not this cache holds the
/// key: a stale cache (another writer put Settings at the key after this pull) and a fresh key both, on every envelope path
/// and in an SoA image, the store unchanged. The pull resolves a record's type from that id, so admitting it would store a
/// Note that every reload serves as Settings.
#[test]
fn retype_an_envelope_claiming_another_registered_types_id_is_refused_at_admission() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.cc");
let open = || -> Result<CultCache> {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
Ok(cache)
};
let mut stale = open()?;
open()?.put("held-elsewhere", &settings_one())?;
let before = fs::read(&path)?;
for key in ["held-elsewhere", "fresh"] {
let (mut stamped, _) = stale.prepare_entry(key, &note_one())?;
stamped.schema_id = Some(Settings::TYPE.to_string());
for error in [
stale.put_envelope::<Note>(stamped.clone()).unwrap_err(),
stale.put_raw_envelope(stamped.clone()).unwrap_err(),
stale.put_prepared_batch(vec![stamped.clone()]).unwrap_err(),
stale.load_soa(CultCacheSoaTable::from_envelopes(vec![stamped.clone()])).unwrap_err(),
] {
let refusal = retype_refusal(&error);
assert_eq!((refusal.record_key.as_str(), refusal.schema_id.as_str()), (key, Settings::TYPE), "{error:#}");
}
}
assert!(stale.snapshot().is_empty());
assert_eq!(fs::read(&path)?, before, "nothing reached the store");
assert_eq!(open()?.get_required::<Settings>("held-elsewhere")?, settings_one());
Ok(())
}
/// A store that holds two records at one key, one stamped with the other's schema id, is refused at the pull: both resolve
/// to one type, and the cache would otherwise keep whichever came last.
#[test]
fn retype_a_store_holding_two_records_at_one_key_refuses_the_pull() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.redb");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
let (settings, _) = cache.prepare_entry("shared", &settings_one())?;
let (mut stamped, _) = cache.prepare_entry("shared", &note_one())?;
stamped.schema_id = Some(Settings::TYPE.to_string());
{
let mut store = RedbMessagePackBackingStore::new(&path)?;
store.push(&settings)?;
store.push(&stamped)?;
}
cache.add_generic_backing_store(RedbMessagePackBackingStore::new(&path)?)?;
let error = cache.pull_all_backing_stores().unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "shared");
assert!(cache.snapshot().is_empty());
Ok(())
}
#[test]
fn retype_two_stores_holding_one_key_refuse_the_pull_and_admit_nothing() -> Result<()> {
let temp = tempfile::tempdir()?;
let (first, second) = (temp.path().join("first.cc"), temp.path().join("second.cc"));
store_with(&first, |cache| cache.put("k", &settings_one()).map(drop))?;
store_with(&second, |cache| cache.put("k", &note_one()).map(drop))?;
let (first_bytes, second_bytes) = (fs::read(&first)?, fs::read(&second)?);
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&first), [Settings::TYPE])?;
cache.pull_all_backing_stores()?;
let before = cache.snapshot();
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&second), [Note::TYPE])?;
let error = cache.pull_all_backing_stores().unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "k");
assert_eq!(cache.snapshot(), before, "a refused pull admits nothing");
assert_eq!((fs::read(&first)?, fs::read(&second)?), (first_bytes, second_bytes));
Ok(())
}
#[test]
fn retype_a_store_with_an_unregistered_type_loads_lists_it_and_keeps_its_bytes() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.cc");
store_with(&path, |cache| {
cache.put("held", &settings_one())?;
cache.put("carried", &note_one()).map(drop)
})?;
let carried = |path: &Path| -> Result<CultCacheEnvelope> {
Ok(reloaded(path)?.into_iter().find(|entry| entry.key == "carried").unwrap())
};
let original = carried(&path)?;
for as_batch in [false, true] {
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
let foreign = cache.foreign_records();
assert_eq!(foreign.len(), 1);
assert_eq!((foreign[0].key.as_str(), foreign[0].schema_id.as_str(), foreign[0].schema_name.as_str()), ("carried", Note::TYPE, Note::TYPE));
assert_eq!(cache.snapshot().len(), 1, "a foreign record is listed, not served");
if as_batch {
let (staged, _) = cache.prepare_entry("other", &settings_one())?;
cache.put_prepared_batch(vec![staged])?;
} else {
cache.put("other", &settings_one())?;
}
assert!(cache.delete::<Settings>("other")?);
assert_eq!(carried(&path)?, original, "the foreign record keeps its bytes (batch {as_batch})");
let secret = Settings { theme: SECRET.to_string(), retries: 9 };
let before = fs::read(&path)?;
let mut refusals = vec![cache.put("carried", &secret).unwrap_err(), cache.delete::<Settings>("carried").unwrap_err()];
let (staged, _) = cache.prepare_entry("carried", &secret)?;
refusals.push(cache.put_prepared_batch(vec![staged]).unwrap_err());
for error in &refusals {
assert_eq!(retype_refusal(error).record_key, "carried");
assert!(!format!("{error:#}").contains(SECRET), "{error:#}");
}
assert_eq!(fs::read(&path)?, before);
}
Ok(())
}
#[test]
fn retype_a_foreign_key_in_another_store_refuses_the_pull_and_every_write_to_it() -> Result<()> {
let temp = tempfile::tempdir()?;
let (routed, generic) = (temp.path().join("routed.cc"), temp.path().join("generic.cc"));
store_with(&routed, |cache| cache.put("k", &settings_one()).map(drop))?;
store_with(&generic, |cache| cache.put("n", &note_one()).map(drop))?;
let open = || -> Result<CultCache> {
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&routed), [Settings::TYPE])?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic))?;
Ok(cache)
};
// A registered record in one store and a foreign one at the same key in another: the pull is refused.
store_with(&generic, |cache| cache.put("k", &note_one()).map(drop))?;
let mut cache = open()?;
assert_eq!(retype_refusal(&cache.pull_all_backing_stores().unwrap_err()).record_key, "k");
assert!(cache.snapshot().is_empty() && cache.foreign_records().is_empty());
// The foreign key lives in the generic store; a write routed to the other store is refused by the cache alone.
fs::remove_file(&generic)?;
store_with(&generic, |cache| cache.put("n", &note_one()).map(drop))?;
let mut cache = open()?;
cache.pull_all_backing_stores()?;
let before = (cache.snapshot(), fs::read(&routed)?, fs::read(&generic)?);
let (staged, _) = cache.prepare_entry("n", &settings_one())?;
for error in [cache.put("n", &settings_one()).unwrap_err(), cache.put_prepared_batch(vec![staged]).unwrap_err()] {
assert_eq!(retype_refusal(&error).record_key, "n");
}
assert_eq!((cache.snapshot(), fs::read(&routed)?, fs::read(&generic)?), before);
Ok(())
}
/// A batch lays back only its home store's foreign records: another store's are never copied into it.
#[test]
fn retype_a_batch_lays_back_only_its_own_stores_foreign_records() -> Result<()> {
let temp = tempfile::tempdir()?;
let (routed, generic) = (temp.path().join("routed.cc"), temp.path().join("generic.cc"));
store_with(&routed, |cache| cache.put("own", &settings_one()).map(drop))?;
store_with(&generic, |cache| cache.put("carried", &note_one()).map(drop))?;
let generic_bytes = fs::read(&generic)?;
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&routed), [Settings::TYPE])?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic))?;
cache.pull_all_backing_stores()?;
let (staged, _) = cache.prepare_entry("other", &settings_one())?;
cache.put_prepared_batch(vec![staged])?;
let keys: Vec<String> = reloaded(&routed)?.into_iter().map(|entry| entry.key).collect();
assert_eq!(keys, ["other", "own"], "the routed store holds its own records only");
assert_eq!(fs::read(&generic)?, generic_bytes);
cache.pull_all_backing_stores()?;
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "only-one", schema = "tests.only_one", global)]
struct OnlyOne {
#[cultcache(key = 0)]
text: String,
}
/// A pull refused after every record is read (here, a second global) changes nothing, the foreign list included.
#[test]
fn retype_a_pull_refused_after_reading_keeps_the_foreign_list() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.cc");
store_with(&path, |cache| cache.put("carried", &note_one()).map(drop))?;
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.register_entry_type::<OnlyOne>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
let before = (cache.snapshot(), cache.foreign_records());
// Behind the cache's back: another foreign record and two records of the global type.
let mut store = SingleFileMessagePackBackingStore::new(&path);
let (mut late, _) = cache.prepare_entry("late", &settings_one())?;
late.r#type = Note::TYPE.to_string();
late.schema_id = Some(Note::TYPE.to_string());
store.push(&late)?;
for key in ["g1", "g2"] {
store.push(&cache.prepare_entry(key, &OnlyOne { text: key.to_string() })?.0)?;
}
assert!(cache.pull_all_backing_stores().is_err());
assert_eq!((cache.snapshot(), cache.foreign_records()), before, "a refused pull changes nothing");
Ok(())
}
/// An SoA image is judged against the foreign keys too: it may not put a record at a key a store carries foreign.
#[test]
fn retype_load_soa_refuses_a_record_at_a_foreign_key() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.cc");
store_with(&path, |cache| cache.put("carried", &note_one()).map(drop))?;
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
let (staged, _) = cache.prepare_entry("carried", &settings_one())?;
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![staged])).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "carried");
assert!(cache.snapshot().is_empty());
Ok(())
}
#[test]
fn retype_load_soa_refuses_a_second_type_at_a_key_and_admits_nothing() -> Result<()> {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.put("kept", &settings_one())?;
let before = cache.snapshot();
let (settings, _) = cache.prepare_entry("k", &settings_one())?;
let (note, _) = cache.prepare_entry("k", &note_one())?;
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![settings, note])).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "k");
assert_eq!(cache.snapshot(), before);
Ok(())
}
#[test]
fn retype_a_later_registration_claims_the_foreign_record_at_the_next_load() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("cache.cc");
store_with(&path, |cache| cache.put("carried", &note_one()).map(drop))?;
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
assert_eq!(cache.foreign_records().len(), 1);
cache.register_entry_type::<Note>()?;
cache.pull_all_backing_stores()?;
assert!(cache.foreign_records().is_empty());
assert_eq!(cache.get_required::<Note>("carried")?, note_one());
Ok(())
}
#[test]
fn a_second_type_under_a_stored_key_is_refused_and_the_store_and_cache_stay() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put("shared", &settings_one())?;
let before = std::fs::read(&store_path)?;
let error = cache.put("shared", &note_one()).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "shared");
assert_eq!(std::fs::read(&store_path)?, before, "the refused write changed the file");
assert_eq!(cache.snapshot().len(), 1);
assert_eq!(cache.get_required::<Settings>("shared")?.theme, "green");
assert!(cache.get::<Note>("shared")?.is_none());
// Retyping is remove-then-add, and what was written reads back.
assert!(cache.delete::<Settings>("shared")?);
cache.put("shared", &note_one())?;
let held = reloaded(&store_path)?;
assert_eq!(held.len(), 1);
assert_eq!(held[0].r#type, Note::TYPE);
// The same type under the key replaces it.
cache.put("shared", &Note { title: "again".to_string(), body: String::new() })?;
let held = reloaded(&store_path)?;
assert_eq!(held.len(), 1);
assert_eq!(cache.get_required::<Note>("shared")?.title, "again");
Ok(())
}
#[test]
fn a_batch_holding_a_second_type_under_a_key_is_refused_whole() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("batch.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put("kept", &settings_one())?;
let before = std::fs::read(&store_path)?;
let (settings, _) = cache.prepare_entry("shared", &settings_one())?;
let (note, _) = cache.prepare_entry("shared", &note_one())?;
let error = cache.put_prepared_batch(vec![settings.clone(), note.clone()]).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "shared");
assert_eq!(std::fs::read(&store_path)?, before);
assert_eq!(cache.snapshot().len(), 1);
// The store's own batch write refuses it too, in either order.
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
for batch in [[settings.clone(), note.clone()], [note, settings]] {
let error = store.push_all(&batch, PushAllOptions::default()).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "shared");
assert_eq!(std::fs::read(&store_path)?, before);
}
Ok(())
}
#[test]
fn the_stores_own_push_refuses_a_second_type_under_a_key() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("push.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
let (settings, _) = cache.prepare_entry("shared", &settings_one())?;
let (note, _) = cache.prepare_entry("shared", &note_one())?;
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
store.push(&settings)?;
let before = std::fs::read(&store_path)?;
let error = store.push(&note).unwrap_err();
let mut names = retype_refusal(&error).schema_names.clone();
names.sort();
assert_eq!(names, vec![Note::TYPE.to_string(), Settings::TYPE.to_string()]);
assert_eq!(std::fs::read(&store_path)?, before);
Ok(())
}
#[test]
fn no_single_file_writer_commits_a_second_type_under_a_held_key() -> Result<()> {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
let (held, _) = cache.prepare_entry("k", &settings_one())?;
let (other_key, _) = cache.prepare_entry("c", &settings_one())?;
let (note, _) = cache.prepare_entry("k", &note_one())?;
let absent_note = CultCacheExpectedEnvelope { key: "k".to_string(), r#type: Note::TYPE.to_string(), current: None };
type Writer = fn(&mut SingleFileMessagePackBackingStore, &[CultCacheEnvelope; 3], &CultCacheExpectedEnvelope) -> Result<bool>;
let writers: Vec<(&str, Writer)> = vec![
("push", |s, e, _| s.push(&e[2]).map(|()| true)),
("push_all", |s, e, _| s.push_all(&[e[0].clone(), e[2].clone()], PushAllOptions::default()).map(|()| true)),
("insert_entry_if_absent", |s, e, _| s.insert_entry_if_absent(e[2].clone())),
("compare_and_swap_entry", |s, e, _| s.compare_and_swap_entry(&e[0], e[2].clone())),
("compare_exchange", |s, e, x| s.compare_exchange(std::slice::from_ref(x), &[e[2].clone()])),
("compare_and_swap_batch", |s, e, _| s.compare_and_swap_batch(&[], vec![e[2].clone()])),
("append_if_snapshot_unchanged", |s, e, _| s.append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()])),
("replace_and_append_if_snapshot_unchanged", |s, e, _| {
s.replace_and_append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()])
}),
("replace_and_delete_if_snapshot_unchanged", |s, e, _| {
s.replace_and_delete_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()], std::slice::from_ref(&e[1]))
}),
("compare_exchange_snapshot", |s, e, _| s.compare_exchange_snapshot(&[e[1].clone(), e[0].clone()], &[e[0].clone(), e[2].clone()])),
("try_compare_exchange_snapshot", |s, e, _| {
s.try_compare_exchange_snapshot(&[e[1].clone(), e[0].clone()], &[e[0].clone(), e[2].clone()]).map(|_| true)
}),
];
let envelopes = [held.clone(), other_key.clone(), note];
for (name, write) in writers {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let mut store = SingleFileMessagePackBackingStore::new(&path);
store.push_all(&[held.clone(), other_key.clone()], PushAllOptions::default())?;
let before = std::fs::read(&path)?;
let error = write(&mut store, &envelopes, &absent_note).expect_err(name);
assert_eq!(retype_refusal(&error).record_key, "k", "{name}");
assert_eq!(std::fs::read(&path)?, before, "{name} changed the file");
assert!(store.pull_all().is_ok(), "{name}");
}
Ok(())
}
#[test]
fn a_legacy_array_with_a_key_under_two_types_is_not_decodable() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("legacy.cc");
let record = |kind: &str| {
CultCacheEnvelope {
key: "a".to_string(),
r#type: kind.to_string(),
payload: rmp_serde::to_vec(&Vec::<u8>::new()).unwrap(),
stored_at: "2026-01-01T00:00:00Z".to_string(),
schema_id: None,
}
};
std::fs::write(&store_path, rmp_serde::to_vec_named(&vec![record("old.one"), record("old.two")])?)?;
let error = SingleFileMessagePackBackingStore::new(&store_path).pull_all().unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}");
Ok(())
}
#[test]
fn prepared_batch_publishes_heterogeneous_entries_in_one_snapshot() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("batch.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
let (settings, _) = cache.prepare_entry(
"app",
&Settings {
theme: "iron".to_string(),
retries: 4,
},
)?;
let (note, _) = cache.prepare_entry(
"receipt",
&Note {
title: "committed".to_string(),
body: "same snapshot".to_string(),
},
)?;
cache.put_prepared_batch(vec![settings, note])?;
let mut reloaded = CultCache::new();
reloaded.register_registry(TestEntries)?;
reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
reloaded.pull_all_backing_stores()?;
assert_eq!(reloaded.get_required::<Settings>("app")?.theme, "iron");
assert_eq!(reloaded.get_required::<Note>("receipt")?.title, "committed");
Ok(())
}
#[test]
fn named_preparation_preserves_omitted_nested_optional_slots() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("named.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<NestedNote>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.pull_all_backing_stores()?;
let note = NestedNote {
value: NestedOptionalValue {
label: "nested".into(),
count: None,
symbol: Some("kept-in-its-own-field".into()),
},
};
assert!(cache.prepare_entry("compact", &note).is_err());
let (entry, parsed) = cache.prepare_entry_named("named", &note)?;
assert_eq!(parsed, note);
cache.put_prepared_batch(vec![entry])?;
assert_eq!(cache.get_required::<NestedNote>("named")?, note);
Ok(())
}
#[test]
fn pulling_an_absent_store_does_not_create_its_parent_or_lock() -> Result<()> {
let temp = tempfile::tempdir()?;
let missing_parent = temp.path().join("missing-body");
let store_path = missing_parent.join("cache.cc");
let store = SingleFileMessagePackBackingStore::new(&store_path);
assert!(store.pull_all()?.is_empty());
assert!(!missing_parent.exists());
assert!(!store_path.with_file_name("cache.cc.lock").exists());
Ok(())
}
#[test]
fn provider_snapshot_read_does_not_create_or_open_a_lock() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("provider.cc");
let lock_path = temp.path().join("provider.cc.lock");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let expected = test_envelope("provider.delivery", "one", b"payload");
store.push(&expected)?;
std::fs::remove_file(&lock_path)?;
assert_eq!(store.pull_all_read_only_snapshot()?, vec![expected]);
assert!(!lock_path.exists());
Ok(())
}
#[test]
fn next_exclusive_write_removes_only_exact_abandoned_staging_files() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let abandoned = temporary_path_for(&store_path);
let unknown = temp.path().join("cache.cc.not-a-uuid.tmp");
let foreign = temp
.path()
.join("other.cc.00000000-0000-0000-0000-000000000000.tmp");
fs::write(&abandoned, b"partial staging bytes")?;
fs::write(&unknown, b"operator-owned unknown file")?;
fs::write(&foreign, b"foreign store staging")?;
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let expected = test_envelope("model", "current", b"one");
store.push(&expected)?;
assert!(!abandoned.exists());
assert_eq!(fs::read(&unknown)?, b"operator-owned unknown file");
assert_eq!(fs::read(&foreign)?, b"foreign store staging");
assert_eq!(store.pull_all()?, vec![expected]);
Ok(())
}
#[test]
fn failed_snapshot_publication_preserves_the_committed_destination() -> Result<()> {
let temp = tempfile::tempdir()?;
let destination = temp.path().join("cache.cc");
let absent_staging = temp.path().join("absent.tmp");
let committed = b"committed snapshot";
fs::write(&destination, committed)?;
assert!(replace_file_atomically(&absent_staging, &destination).is_err());
assert_eq!(fs::read(&destination)?, committed);
Ok(())
}
#[test]
fn snapshot_publication_atomically_replaces_an_existing_destination() -> Result<()> {
let temp = tempfile::tempdir()?;
let destination = temp.path().join("cache.cc");
let staged = temp.path().join("cache.cc.staged");
fs::write(&destination, b"old snapshot")?;
fs::write(&staged, b"new snapshot")?;
replace_file_atomically(&staged, &destination)?;
assert_eq!(fs::read(&destination)?, b"new snapshot");
assert!(!staged.exists());
Ok(())
}
#[test]
#[ignore = "operator SIGKILL probe; requires CULTCACHE_INTERRUPTION_STORE"]
fn snapshot_publication_interruption_probe_writer() -> Result<()> {
let store_path = std::env::var_os("CULTCACHE_INTERRUPTION_STORE")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_STORE is required"))?;
let marker_path = std::env::var_os("CULTCACHE_INTERRUPTION_MARKER")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_MARKER is required"))?;
let mut store = SingleFileMessagePackBackingStore::new(store_path);
for sequence in 0_u64.. {
store.push(&test_envelope(
"interruption.probe",
"current",
&rmp_serde::to_vec(&sequence)?,
))?;
fs::write(&marker_path, sequence.to_string())?;
}
unreachable!("the interruption writer is stopped by the operator")
}
#[test]
#[ignore = "operator SIGKILL probe; requires CULTCACHE_INTERRUPTION_STORE"]
fn snapshot_publication_interruption_probe_reader() -> Result<()> {
let store_path = std::env::var_os("CULTCACHE_INTERRUPTION_STORE")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_STORE is required"))?;
let rows = SingleFileMessagePackBackingStore::new(store_path).pull_all()?;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].r#type, "interruption.probe");
assert_eq!(rows[0].key, "current");
let _: u64 = rmp_serde::from_slice(&rows[0].payload)?;
Ok(())
}
fn test_envelope(r#type: &str, key: &str, payload: &[u8]) -> CultCacheEnvelope {
CultCacheEnvelope {
key: key.to_string(),
r#type: r#type.to_string(),
payload: payload.to_vec(),
stored_at: "2026-07-13T00:00:00Z".to_string(),
schema_id: Some(r#type.to_string()),
}
}
#[test]
fn redb_store_crud_and_reopen_preserve_polymorphic_rows() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("keyed.cc");
let model = test_envelope("model", "shared", b"one");
let receipt = test_envelope("receipt", "shared", b"two");
{
let mut store = RedbMessagePackBackingStore::new(&path)?;
store.push(&model)?;
store.push(&receipt)?;
assert_eq!(store.pull_all()?.len(), 2);
store.delete(&receipt)?;
}
let reopened = RedbMessagePackBackingStore::new(&path)?;
assert_eq!(reopened.pull_all()?, vec![model]);
Ok(())
}
#[test]
fn redb_exact_cas_refuses_stale_value_without_mutation() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut store = RedbMessagePackBackingStore::new(temp.path().join("cas.cc"))?;
let current = test_envelope("model", "current", b"one");
store.push(&current)?;
let stale = test_envelope("model", "current", b"stale");
let replacement = test_envelope("model", "current", b"two");
assert!(!store.compare_and_swap_entry(&stale, replacement.clone())?);
assert_eq!(store.pull_all()?, vec![current.clone()]);
assert!(store.compare_and_swap_entry(&current, replacement.clone())?);
assert_eq!(store.pull_all()?, vec![replacement]);
Ok(())
}
#[test]
fn redb_batch_is_atomic_and_stale_member_refuses_every_write() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut store = RedbMessagePackBackingStore::new(temp.path().join("batch.cc"))?;
let first = test_envelope("model", "first", b"one");
let second = test_envelope("model", "second", b"one");
store.push(&first)?;
store.push(&second)?;
let stale_second = test_envelope("model", "second", b"stale");
let first_two = test_envelope("model", "first", b"two");
let second_two = test_envelope("model", "second", b"two");
let receipt = test_envelope("receipt", "batch", b"committed");
assert!(!store.compare_and_swap_batch(
&[first.clone(), stale_second],
vec![first_two.clone(), second_two.clone(), receipt.clone()],
)?);
assert_eq!(store.pull_all()?, vec![first.clone(), second.clone()]);
assert!(store.compare_and_swap_batch(
&[first, second],
vec![first_two.clone(), second_two.clone(), receipt.clone()],
)?);
assert_eq!(store.pull_all()?, vec![first_two, second_two, receipt]);
Ok(())
}
#[test]
fn redb_compaction_refuses_stale_snapshot_and_atomically_replaces_and_deletes() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut store = RedbMessagePackBackingStore::new(temp.path().join("compact.cc"))?;
let old_head = test_envelope("retention", "current", b"one");
let retired = test_envelope("receipt", "old", b"terminal");
store.push(&old_head)?;
store.push(&retired)?;
let stale = store.pull_all()?;
let concurrent = test_envelope("receipt", "running", b"active");
assert!(store.insert_entry_if_absent(concurrent.clone())?);
let new_head = test_envelope("retention", "current", b"two");
assert!(!store.replace_and_delete_if_snapshot_unchanged(
&stale,
vec![new_head.clone()],
std::slice::from_ref(&retired),
)?);
assert!(store.pull_all()?.contains(&retired));
let exact = store.pull_all()?;
assert!(store.replace_and_delete_if_snapshot_unchanged(
&exact,
vec![new_head.clone()],
&[retired],
)?);
assert_eq!(store.pull_all()?, vec![concurrent, new_head]);
Ok(())
}
#[test]
fn redb_entry_cas_does_not_depend_on_unrelated_snapshot_changes() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = RedbMessagePackBackingStore::new(temp.path().join("independent.cc"))?;
let current = test_envelope("model", "current", b"one");
assert!(store.insert_entry_if_absent(current.clone())?);
let unrelated = test_envelope("event", "later", b"noise");
assert!(store.insert_entry_if_absent(unrelated.clone())?);
let replacement = test_envelope("model", "current", b"two");
assert!(store.compare_and_swap_entry(&current, replacement.clone())?);
assert_eq!(store.pull_all()?, vec![unrelated, replacement]);
Ok(())
}
#[test]
fn redb_fresh_handles_share_one_database_for_the_same_cultcache_path() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("shared.cc");
let mut first = RedbMessagePackBackingStore::new(&path)?;
let second = RedbMessagePackBackingStore::new(&path)?;
let initial = test_envelope("model", "current", b"one");
first.push(&initial)?;
assert_eq!(second.pull_all()?, vec![initial.clone()]);
let replacement = test_envelope("model", "current", b"two");
assert!(second.compare_and_swap_entry(&initial, replacement.clone())?);
assert_eq!(first.pull_all()?, vec![replacement]);
Ok(())
}
#[test]
fn owned_redb_clones_share_authority_and_fresh_owner_is_refused() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("owned.cc");
let owner = OwnedRedbMessagePackBackingStore::new(&path)?;
let clone = owner.clone();
assert_eq!(owner.file_identity(), clone.file_identity());
assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err());
drop(owner);
assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err());
drop(clone);
assert!(OwnedRedbMessagePackBackingStore::new(&path).is_ok());
Ok(())
}
#[test]
fn owned_redb_file_identity_is_stable_across_writes_and_reopen() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("identity.cc");
let identity = {
let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?;
let identity = owner.file_identity().to_string();
owner.push(&test_envelope("model", "current", b"one"))?;
owner.validate_path_identity()?;
owner.require_file_identity(&identity)?;
identity
};
let reopened = OwnedRedbMessagePackBackingStore::new(&path)?;
assert_eq!(reopened.file_identity(), identity);
assert_eq!(reopened.pull_all()?.len(), 1);
Ok(())
}
#[test]
fn owned_redb_identity_helper_refuses_mismatch() -> Result<()> {
let temp = tempfile::tempdir()?;
let owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("mismatch.cc"))?;
assert!(owner.require_file_identity("not-the-owned-file").is_err());
owner.require_file_identity(owner.file_identity())?;
Ok(())
}
#[test]
fn owned_redb_batch_refuses_stale_member_and_commits_success_atomically() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("batch-owned.cc"))?;
let first = test_envelope("model", "first", b"one");
let second = test_envelope("model", "second", b"one");
owner.push(&first)?;
owner.push(&second)?;
let first_two = test_envelope("model", "first", b"two");
let second_two = test_envelope("model", "second", b"two");
let companion = test_envelope("receipt", "batch", b"committed");
let stale_second = test_envelope("model", "second", b"stale");
assert!(!owner.compare_and_swap_batch(
&[first.clone(), stale_second],
vec![first_two.clone(), second_two.clone(), companion.clone()],
)?);
assert_eq!(owner.pull_all()?, vec![first.clone(), second.clone()]);
assert!(owner.compare_and_swap_batch(
&[first, second],
vec![first_two.clone(), second_two.clone(), companion.clone()],
)?);
assert_eq!(owner.pull_all()?, vec![first_two, second_two, companion]);
Ok(())
}
#[test]
fn owned_redb_compaction_refuses_stale_snapshot_and_atomically_replaces_and_deletes()
-> Result<()> {
let temp = tempfile::tempdir()?;
let mut owner =
OwnedRedbMessagePackBackingStore::new(temp.path().join("compact-owned.cc"))?;
let old_head = test_envelope("retention", "current", b"one");
let retired_a = test_envelope("claim", "a", b"terminal");
let retired_b = test_envelope("receipt", "a", b"terminal");
owner.push(&old_head)?;
owner.push(&retired_a)?;
owner.push(&retired_b)?;
let stale = owner.pull_all()?;
let concurrent = test_envelope("claim", "concurrent", b"running");
assert!(owner.insert_entry_if_absent(concurrent.clone())?);
let new_head = test_envelope("retention", "current", b"two");
assert!(!owner.replace_and_delete_if_snapshot_unchanged(
&stale,
vec![new_head.clone()],
&[retired_a.clone(), retired_b.clone()],
)?);
assert!(owner.pull_all()?.contains(&retired_a));
let exact = owner.pull_all()?;
assert!(owner.replace_and_delete_if_snapshot_unchanged(
&exact,
vec![new_head.clone()],
&[retired_a, retired_b],
)?);
assert_eq!(owner.pull_all()?, vec![concurrent, new_head]);
Ok(())
}
#[test]
fn owned_redb_path_replacement_cannot_redirect_pinned_authority() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("pinned.cc");
let displaced = temp.path().join("displaced.cc");
let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?;
let identity = owner.file_identity().to_string();
owner.push(&test_envelope("model", "before", b"one"))?;
fs::rename(&path, &displaced)?;
File::create(&path)?;
assert!(owner.validate_path_identity().is_err());
assert_eq!(owner.file_identity(), identity);
owner.push(&test_envelope("model", "after", b"two"))?;
assert_eq!(owner.pull_all()?.len(), 2);
assert_ne!(
file_identity_from_file(&File::open(&path)?)?,
owner.file_identity()
);
Ok(())
}
#[test]
fn redb_overlapping_fresh_handles_serialize_the_database_open_interval() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("contended.cc");
let first = RedbMessagePackBackingStore::new(&path)?;
let second = RedbMessagePackBackingStore::new(&path)?;
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let first_barrier = barrier.clone();
let first_thread = std::thread::spawn(move || -> Result<()> {
first_barrier.wait();
for index in 0..32 {
first.insert_entry_if_absent(test_envelope(
"event",
&format!("first-{index}"),
b"first",
))?;
}
Ok(())
});
let second_barrier = barrier.clone();
let second_thread = std::thread::spawn(move || -> Result<()> {
second_barrier.wait();
for index in 0..32 {
second.insert_entry_if_absent(test_envelope(
"event",
&format!("second-{index}"),
b"second",
))?;
}
Ok(())
});
barrier.wait();
first_thread.join().expect("first redb writer")?;
second_thread.join().expect("second redb writer")?;
assert_eq!(
RedbMessagePackBackingStore::new(path)?.pull_all()?.len(),
64
);
Ok(())
}
#[test]
fn redb_accepts_a_bare_relative_cultcache_path() -> Result<()> {
let name = format!("cultcache-redb-relative-{}.cc", uuid::Uuid::new_v4());
let path = PathBuf::from(&name);
let lock_path = PathBuf::from(format!("{name}.lock"));
let store = RedbMessagePackBackingStore::new(&path)?;
assert!(store.insert_entry_if_absent(test_envelope("model", "current", b"one"))?);
assert_eq!(store.pull_all()?.len(), 1);
fs::remove_file(path)?;
fs::remove_file(lock_path)?;
Ok(())
}
#[test]
fn conditional_batch_replaces_expected_and_inserts_companion_atomically() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("conditional-batch.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let current = test_envelope("model", "current", b"revision-1");
store.push(&current)?;
let replacement = test_envelope("model", "current", b"revision-2");
let companion = test_envelope("receipt", "migration-1", b"applied");
assert!(store.compare_and_swap_batch(
std::slice::from_ref(&current),
vec![replacement.clone(), companion.clone()],
)?);
assert_eq!(store.pull_all()?, vec![replacement, companion]);
Ok(())
}
#[test]
fn conditional_batch_allows_atomic_first_import_when_identities_are_absent() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("conditional-import.cc");
let store = SingleFileMessagePackBackingStore::new(&store_path);
let model = test_envelope("model", "current", b"revision-1");
let receipt = test_envelope("receipt", "migration-1", b"applied");
assert!(store.compare_and_swap_batch(&[], vec![model.clone(), receipt.clone()])?);
assert_eq!(store.pull_all()?, vec![model, receipt]);
Ok(())
}
#[test]
fn conditional_batch_stale_expected_and_companion_collision_preserve_exact_bytes() -> Result<()>
{
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("conditional-refusal.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let current = test_envelope("model", "current", b"revision-1");
let occupied = test_envelope("receipt", "migration-1", b"someone-else");
store.push(&current)?;
store.push(&occupied)?;
let before = fs::read(&store_path)?;
let stale = test_envelope("model", "current", b"stale-copy");
let replacement = test_envelope("model", "current", b"revision-2");
let fresh_companion = test_envelope("receipt", "migration-2", b"applied");
assert!(
!store.compare_and_swap_batch(&[stale], vec![replacement.clone(), fresh_companion],)?
);
assert_eq!(fs::read(&store_path)?, before);
assert!(!store.compare_and_swap_batch(
&[current],
vec![
replacement,
test_envelope("receipt", "migration-1", b"collision")
],
)?);
assert_eq!(fs::read(&store_path)?, before);
Ok(())
}
#[test]
fn conditional_batch_rejects_empty_replacements_and_duplicate_identities() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = SingleFileMessagePackBackingStore::new(temp.path().join("invalid-batch.cc"));
let entry = test_envelope("model", "current", b"one");
assert!(
store
.compare_and_swap_batch(&[entry.clone()], Vec::new())
.is_err()
);
assert!(
store
.compare_and_swap_batch(&[], vec![entry.clone(), entry.clone()])
.is_err()
);
assert!(
store
.compare_and_swap_batch(
&[entry.clone(), entry.clone()],
vec![test_envelope("model", "current", b"two")],
)
.is_err()
);
assert!(!store.path().exists());
Ok(())
}
#[test]
fn full_snapshot_replacement_and_append_refuses_concurrent_rows() -> Result<()> {
let temp = tempfile::tempdir()?;
let snapshot_path = temp.path().join("snapshot-replace-append.cc");
let mut snapshot_store = SingleFileMessagePackBackingStore::new(&snapshot_path);
let current = test_envelope("session", "current", b"active");
snapshot_store.push(&current)?;
let stale_snapshot = snapshot_store.pull_all()?;
let concurrent = test_envelope("event", "concurrent", b"live");
snapshot_store.push(&concurrent)?;
let completed = test_envelope("session", "current", b"completed");
let receipt = test_envelope("receipt", "terminal", b"done");
assert!(!snapshot_store.replace_and_append_if_snapshot_unchanged(
&stale_snapshot,
vec![completed.clone(), receipt.clone()],
)?);
assert_eq!(
snapshot_store.pull_all()?,
vec![concurrent.clone(), current.clone()]
);
let exact_snapshot = snapshot_store.pull_all()?;
assert!(snapshot_store.replace_and_append_if_snapshot_unchanged(
&exact_snapshot,
vec![completed.clone(), receipt.clone()],
)?);
assert_eq!(
snapshot_store.pull_all()?,
vec![concurrent.clone(), receipt.clone(), completed.clone()]
);
let keyed_path = temp.path().join("keyed-replace-append.redb");
let mut keyed_store = RedbMessagePackBackingStore::new(&keyed_path)?;
keyed_store.push(&current)?;
let stale_snapshot = keyed_store.pull_all()?;
keyed_store.push(&concurrent)?;
assert!(!keyed_store.replace_and_append_if_snapshot_unchanged(
&stale_snapshot,
vec![completed.clone(), receipt.clone()],
)?);
let exact_snapshot = keyed_store.pull_all()?;
assert!(keyed_store.replace_and_append_if_snapshot_unchanged(
&exact_snapshot,
vec![completed.clone(), receipt.clone()],
)?);
assert_eq!(
keyed_store.pull_all()?,
vec![concurrent, receipt, completed]
);
Ok(())
}
#[test]
fn single_file_compaction_atomically_replaces_summary_and_deletes_history() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("conditional-compaction.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let history = test_envelope("history", "old", b"closed");
let head = test_envelope("retention", "head", b"revision-1");
store.push(&history)?;
store.push(&head)?;
let snapshot = store.pull_all()?;
let next_head = test_envelope("retention", "head", b"revision-2");
assert!(store.replace_and_delete_if_snapshot_unchanged(
&snapshot,
vec![next_head.clone()],
std::slice::from_ref(&history),
)?);
assert_eq!(store.pull_all()?, vec![next_head]);
let before = fs::read(&store_path)?;
assert!(!store.replace_and_delete_if_snapshot_unchanged(
&snapshot,
vec![test_envelope("retention", "head", b"revision-3")],
std::slice::from_ref(&history),
)?);
assert_eq!(fs::read(&store_path)?, before);
Ok(())
}
#[test]
fn snapshot_append_fences_concurrent_unknown_identity() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("snapshot-append.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let model = test_envelope("model", "current", b"revision-1");
store.push(&model)?;
let expected = store.pull_all()?;
let duplicate_admission = test_envelope("admission", "unexpected", b"also-current");
store.push(&duplicate_admission)?;
let before = fs::read(&store_path)?;
assert!(!store.append_if_snapshot_unchanged(
&expected,
vec![test_envelope("readiness", "proof", b"ready")],
)?);
assert_eq!(fs::read(&store_path)?, before);
Ok(())
}
#[test]
fn snapshot_append_is_order_insensitive_and_refuses_collisions() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("snapshot-order.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let a = test_envelope("a", "one", b"1");
let b = test_envelope("b", "two", b"2");
store.push(&a)?;
store.push(&b)?;
assert!(store.append_if_snapshot_unchanged(
&[b.clone(), a.clone()],
vec![test_envelope("proof", "three", b"3")],
)?);
let current = store.pull_all()?;
assert!(!store.append_if_snapshot_unchanged(
&current,
vec![test_envelope("a", "one", b"collision")],
)?);
assert!(
store
.append_if_snapshot_unchanged(&current, Vec::new())
.is_err()
);
Ok(())
}
#[test]
fn type_and_key_identity_is_not_delimiter_ambiguous() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("delimiter-identity.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
let left = test_envelope("a::b", "c", b"left");
let right = test_envelope("a", "b::c", b"right");
store.push(&left)?;
store.push(&right)?;
let rows = store.pull_all()?;
assert_eq!(rows.len(), 2);
assert!(rows.contains(&left));
assert!(rows.contains(&right));
Ok(())
}
#[test]
fn refused_batch_preserves_prior_snapshot() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("refused-batch.cc");
let refusal = Arc::new(AtomicBool::new(false));
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(RefusingBatchStore {
inner: SingleFileMessagePackBackingStore::new(&store_path),
refuse_batch: refusal.clone(),
})?;
cache.put(
"app",
&Settings {
theme: "before".to_string(),
retries: 1,
},
)?;
let (note, _) = cache.prepare_entry(
"receipt",
&Note {
title: "after".to_string(),
body: "must not land".to_string(),
},
)?;
refusal.store(true, Ordering::SeqCst);
assert!(cache.put_prepared_batch(vec![note]).is_err());
assert!(cache.get::<Note>("receipt")?.is_none());
let mut reloaded = CultCache::new();
reloaded.register_registry(TestEntries)?;
reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
reloaded.pull_all_backing_stores()?;
assert_eq!(reloaded.get_required::<Settings>("app")?.theme, "before");
assert!(reloaded.get::<Note>("receipt")?.is_none());
Ok(())
}
#[test]
fn type_specific_store_routes_before_generic_store() -> Result<()> {
let temp = tempfile::tempdir()?;
let generic_path = temp.path().join("generic.cc");
let settings_path = temp.path().join("settings.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.register_entry_type::<Note>()?;
cache.add_backing_store(
SingleFileMessagePackBackingStore::new(&settings_path),
["settings"],
)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?;
cache.put(
"app",
&Settings {
theme: "ash".to_string(),
retries: 3,
},
)?;
cache.put(
"memo",
&Note {
title: "hello".to_string(),
body: "world".to_string(),
},
)?;
let generic_entries = SingleFileMessagePackBackingStore::new(&generic_path).pull_all()?;
let settings_entries = SingleFileMessagePackBackingStore::new(&settings_path).pull_all()?;
assert_eq!(generic_entries[0].r#type, "note");
assert_eq!(settings_entries[0].r#type, "settings");
Ok(())
}
#[test]
fn second_generic_store_is_rejected() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut cache = CultCache::new();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")))?;
let error = cache
.add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("b.cc")))
.unwrap_err();
assert!(error.to_string().contains("second generic store"), "{error}");
assert_eq!(cache.stores.len(), 1);
Ok(())
}
#[test]
fn type_claimed_twice_is_rejected() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut cache = CultCache::new();
cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")), ["settings"])?;
let error = cache
.add_backing_store(
SingleFileMessagePackBackingStore::new(temp.path().join("b.cc")),
["note", "settings"],
)
.unwrap_err();
assert!(error.to_string().contains("already routed"), "{error}");
// A refused registration claims nothing, so "note" is still free.
cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("c.cc")), ["note"])?;
assert_eq!(cache.stores.len(), 2);
Ok(())
}
#[test]
fn attach_that_would_move_a_held_record_home_is_refused() -> Result<()> {
let temp = tempfile::tempdir()?;
let generic_path = temp.path().join("generic.cc");
let settings_path = temp.path().join("settings.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?;
cache.put("app", &Settings { theme: "v1-generic".to_string(), retries: 1 })?;
let error = cache
.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])
.unwrap_err();
assert!(
error
.to_string()
.contains("would move \"settings\" from the generic store to the store routed to [\"settings\"]"),
"{error}"
);
assert_eq!(cache.stores.len(), 1);
// Nothing attached: the next write still lands in the generic store.
cache.put("app", &Settings { theme: "v2-generic".to_string(), retries: 2 })?;
let generic_entries = SingleFileMessagePackBackingStore::new(&generic_path).pull_all()?;
assert_eq!(generic_entries.len(), 1);
assert_eq!(generic_entries[0].r#type, "settings");
assert!(!settings_path.exists());
Ok(())
}
#[test]
fn load_from_a_store_that_is_not_the_record_home_is_refused() -> Result<()> {
let temp = tempfile::tempdir()?;
let generic_path = temp.path().join("generic.cc");
let settings_path = temp.path().join("settings.cc");
let mut writer = CultCache::new();
writer.register_entry_type::<Settings>()?;
writer.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?;
writer.put("app", &Settings { theme: "stray".to_string(), retries: 0 })?;
let generic_bytes = fs::read(&generic_path)?;
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?;
// The cache already holds a record, in its home store, before the refused load.
cache.put("held", &Settings { theme: "home".to_string(), retries: 0 })?;
let before = cache.snapshot();
let settings_bytes = fs::read(&settings_path)?;
let error = cache.pull_all_backing_stores().unwrap_err();
assert!(
error.to_string().contains(
"\"settings\" record \"app\" was loaded from the generic store, but its home is the store routed to [\"settings\"]"
),
"{error}"
);
assert_eq!(cache.snapshot(), before);
assert_eq!(cache.get_required::<Settings>("held")?.theme, "home");
assert_eq!(fs::read(&generic_path)?, generic_bytes);
assert_eq!(fs::read(&settings_path)?, settings_bytes);
Ok(())
}
#[test]
fn refused_pull_leaves_prior_view_intact() -> Result<()> {
let temp = tempfile::tempdir()?;
let (settings_path, generic_path) = (temp.path().join("settings.cc"), temp.path().join("generic.cc"));
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), [Settings::TYPE])?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?;
cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?;
let before = cache.snapshot();
// A record of a type this cache never registered lands, behind its back, in the other store at a held key.
SingleFileMessagePackBackingStore::new(&generic_path).push(&CultCacheEnvelope {
key: "app".into(),
r#type: "unregistered".into(),
payload: rmp_serde::to_vec(&("x",))?,
stored_at: "2026-09-14T00:00:00Z".into(),
schema_id: Some("unregistered".into()),
})?;
let error = cache.pull_all_backing_stores().unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "app");
assert_eq!(cache.snapshot(), before);
assert!(cache.foreign_records().is_empty());
assert_eq!(cache.get_required::<Settings>("app")?.theme, "before");
Ok(())
}
#[test]
fn refused_soa_load_leaves_prior_view_intact() -> Result<()> {
let mut source = CultCache::new();
source.register_registry(TestEntries)?;
source.put("app", &Settings { theme: "ash".to_string(), retries: 3 })?;
source.put("memo", &Note { title: "machine".to_string(), body: "awake".to_string() })?;
let table = source.snapshot_soa();
// A type with no registration.
let mut unregistered = CultCache::new();
unregistered.register_entry_type::<Settings>()?;
unregistered.put("held", &Settings { theme: "held".to_string(), retries: 0 })?;
let before = unregistered.snapshot();
let error = unregistered.load_soa(table.clone()).unwrap_err();
assert!(error.to_string().contains("\"note\""), "{error}");
assert_eq!(unregistered.snapshot(), before);
// A registered type with no home once a store is attached.
let temp = tempfile::tempdir()?;
let mut homeless = CultCache::new();
homeless.register_registry(TestEntries)?;
homeless.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("settings.cc")), ["settings"])?;
homeless.put("held", &Settings { theme: "held".to_string(), retries: 0 })?;
let before = homeless.snapshot();
let error = homeless.load_soa(table).unwrap_err();
assert!(error.to_string().contains("No backing store is registered for entry type \"note\""), "{error}");
assert_eq!(homeless.snapshot(), before);
Ok(())
}
#[test]
fn single_home_batch_is_not_refused_by_other_homes_records() -> Result<()> {
let temp = tempfile::tempdir()?;
let generic_path = temp.path().join("generic.cc");
let settings_path = temp.path().join("settings.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?;
cache.put("memo", &Note { title: "generic".to_string(), body: "home".to_string() })?;
cache.put("old", &Settings { theme: "kept".to_string(), retries: 0 })?;
let (staged, _) = cache.prepare_entry("app", &Settings { theme: "batch".to_string(), retries: 1 })?;
cache.put_prepared_batch(vec![staged])?;
let mut settings_keys: Vec<_> = SingleFileMessagePackBackingStore::new(&settings_path)
.pull_all()?
.into_iter()
.map(|entry| format!("{}:{}", entry.r#type, entry.key))
.collect();
settings_keys.sort();
assert_eq!(settings_keys, vec!["settings:app".to_string(), "settings:old".to_string()]);
let generic_keys: Vec<_> = SingleFileMessagePackBackingStore::new(&generic_path)
.pull_all()?
.into_iter()
.map(|entry| format!("{}:{}", entry.r#type, entry.key))
.collect();
assert_eq!(generic_keys, vec!["note:memo".to_string()]);
assert_eq!(cache.get_required::<Settings>("app")?.theme, "batch");
Ok(())
}
#[test]
fn batch_with_empty_key_or_stored_at_is_refused_before_any_store() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("generic.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?;
let before = cache.snapshot();
let store_bytes = fs::read(&store_path)?;
let (empty_key, _) = cache.prepare_entry("", &Note { title: "t".to_string(), body: "b".to_string() })?;
let error = cache.put_prepared_batch(vec![empty_key]).unwrap_err();
assert!(error.to_string().contains("keys for type \"note\" must be non-empty"), "{error}");
let (mut empty_stored_at, _) = cache.prepare_entry("n", &Note { title: "t".to_string(), body: "b".to_string() })?;
empty_stored_at.stored_at = " ".to_string();
let error = cache.put_prepared_batch(vec![empty_stored_at]).unwrap_err();
assert!(error.to_string().contains("stored_at for type \"note\" must be non-empty"), "{error}");
assert_eq!(cache.snapshot(), before);
assert_eq!(fs::read(&store_path)?, store_bytes);
Ok(())
}
#[test]
fn zero_store_cache_writes_and_deletes_in_memory() -> Result<()> {
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.put("x", &Settings { theme: "mem".to_string(), retries: 0 })?;
assert_eq!(cache.get_required::<Settings>("x")?.theme, "mem");
let (staged, _) = cache.prepare_entry("y", &Settings { theme: "batch".to_string(), retries: 0 })?;
cache.put_prepared_batch(vec![staged])?;
assert_eq!(cache.get_required::<Settings>("y")?.theme, "batch");
assert!(cache.delete::<Settings>("x")?);
assert!(cache.get::<Settings>("x")?.is_none());
Ok(())
}
#[test]
fn update_and_delete_follow_the_cache_api() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
let updated = cache.update::<Settings, _>("app", |current| {
let mut current = current.unwrap_or(Settings {
theme: "ash".to_string(),
retries: 0,
});
current.retries += 1;
current
})?;
assert_eq!(updated.retries, 1);
assert!(cache.delete::<Settings>("app")?);
assert!(cache.get::<Settings>("app")?.is_none());
Ok(())
}
#[test]
fn payload_is_binary_messagepack_not_json_value() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put(
"app",
&Settings {
theme: "ash".to_string(),
retries: 3,
},
)?;
let entry = cache.snapshot().remove(0);
let decoded: Settings = rmp_serde::from_slice(&entry.payload)?;
assert_eq!(decoded.theme, "ash");
assert!(!entry.payload.is_empty());
Ok(())
}
#[test]
fn byte_slots_use_messagepack_binary_and_round_trip() -> Result<()> {
let record = BinaryRecord {
required: vec![1, 2, 3],
optional: Vec::new(),
};
let payload = rmp_serde::to_vec(&record)?;
assert_eq!(payload, vec![0x92, 0xc4, 3, 1, 2, 3, 0xc4, 0]);
assert_eq!(rmp_serde::from_slice::<BinaryRecord>(&payload)?, record);
Ok(())
}
#[test]
fn corrupted_payload_fails_during_typed_retrieval() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
store.push(&CultCacheEnvelope {
key: "app".to_string(),
r#type: "settings".to_string(),
payload: vec![0xc1],
stored_at: now_utc_second(),
schema_id: Some("settings".to_string()),
})?;
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.pull_all_backing_stores()?;
let error = cache.get_required::<Settings>("app").unwrap_err();
assert!(
error
.to_string()
.contains("failed to decode CultCache entry")
);
Ok(())
}
#[test]
fn put_envelope_reuses_existing_messagepack_payload() -> Result<()> {
let temp = tempfile::tempdir()?;
let origin_store = temp.path().join("origin.cc");
let target_store = temp.path().join("target.cc");
let mut origin = CultCache::new();
origin.register_entry_type::<Settings>()?;
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?;
origin.put(
"app",
&Settings {
theme: "ash".to_string(),
retries: 3,
},
)?;
let envelope = origin.get_required_envelope::<Settings>("app")?;
let mut target = CultCache::new();
target.register_entry_type::<Settings>()?;
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?;
let applied = target.put_envelope::<Settings>(envelope.clone())?;
assert_eq!(
applied,
Settings {
theme: "ash".to_string(),
retries: 3,
}
);
assert_eq!(target.get_required::<Settings>("app")?, applied);
assert_eq!(
target.get_required_envelope::<Settings>("app")?.payload,
envelope.payload
);
Ok(())
}
#[test]
fn raw_envelope_path_preserves_a_registered_dynamic_payload() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("dynamic.cc");
let envelope = CultCacheEnvelope {
key: "app".to_string(),
r#type: Settings::TYPE.to_string(),
payload: vec![0xc1],
stored_at: now_utc_second(),
schema_id: Some("dynamic.settings.v1".to_string()),
};
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.register_entry_type::<Note>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put_raw_envelope(envelope.clone())?;
assert_eq!(cache.snapshot(), vec![envelope.clone()]);
assert_eq!(
SingleFileMessagePackBackingStore::new(&store_path).pull_all()?,
vec![envelope]
);
assert!(cache.get_required::<Settings>("app").is_err());
let before_collision = fs::read(&store_path)?;
assert!(
cache
.put_raw_envelope(CultCacheEnvelope {
key: "note".to_string(),
r#type: Note::TYPE.to_string(),
payload: Vec::new(),
stored_at: now_utc_second(),
schema_id: Some("dynamic.settings.v1".to_string()),
})
.is_err()
);
assert_eq!(fs::read(&store_path)?, before_collision);
assert!(
cache
.put_raw_envelope(CultCacheEnvelope {
key: "foreign".to_string(),
r#type: "unregistered".to_string(),
payload: Vec::new(),
stored_at: now_utc_second(),
schema_id: None,
})
.is_err()
);
Ok(())
}
#[test]
fn soa_snapshot_round_trips_registered_entries() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put(
"app",
&Settings {
theme: "ash".to_string(),
retries: 3,
},
)?;
cache.put(
"memo",
&Note {
title: "machine".to_string(),
body: "awake".to_string(),
},
)?;
let soa = cache.snapshot_soa();
assert_eq!(soa.len(), 2);
assert_eq!(soa.keys.len(), soa.payloads.len());
let mut restored = CultCache::new();
restored.register_registry(TestEntries)?;
restored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(
temp.path().join("restored.cc"),
))?;
restored.load_soa(soa)?;
assert_eq!(restored.get_required::<Settings>("app")?.theme, "ash");
assert_eq!(restored.get_required::<Note>("memo")?.body, "awake");
Ok(())
}
#[test]
fn typed_soa_columns_match_csharp_cache_ergonomics() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("cache.cc");
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put(
"settings:a",
&Settings {
theme: "ash".to_string(),
retries: 3,
},
)?;
cache.put(
"settings:b",
&Settings {
theme: "bone".to_string(),
retries: 5,
},
)?;
let table = cache.soa::<Settings>()?;
assert_eq!(
table.keys(),
&["settings:a".to_string(), "settings:b".to_string()]
);
assert_eq!(
table.column::<String>("theme")?.values(),
&["ash".to_string(), "bone".to_string()]
);
assert_eq!(table.column::<u32>("retries")?.values(), &[3, 5]);
let error = table.column::<String>("retries").unwrap_err();
assert!(error.to_string().contains("not alloc::string::String"));
Ok(())
}
#[test]
fn soa_rejects_column_length_drift() {
let table = CultCacheSoaTable {
keys: vec!["app".to_string()],
types: Vec::new(),
payloads: Vec::new(),
stored_ats: Vec::new(),
schema_ids: Vec::new(),
};
let error = table.validate().unwrap_err();
assert!(error.to_string().contains("column types has length 0"));
}
#[test]
fn messagepack_store_refuses_a_record_whose_schema_the_catalog_does_not_publish() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("missing-catalog.msgpack");
let expected = SchemaStamped {
schema_version: "tests.schema_stamped_entry.v1".to_string(),
name: "schema-stamped".to_string(),
value: "still readable".to_string(),
};
// An empty catalog with a stale schema id.
let snapshot = PersistedStoreSnapshot(
"cultcache.store.v1".to_string(),
Vec::new(),
vec![PersistedRecord(
"record-1".to_string(),
"sha256:stale-schema-id-from-cold-record".to_string(),
"2026-06-25T12:00:00Z".to_string(),
rmp_serde::to_vec(&expected)?,
)],
);
std::fs::write(&store_path, rmp_serde::to_vec(&snapshot)?)?;
let mut cache = CultCache::new();
cache.register_entry_type::<SchemaStamped>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
// The payload opens with a schema version, and the catalog is the store's own description: no recovery.
let error = cache.pull_all_backing_stores().unwrap_err();
assert!(error.downcast_ref::<CultCacheStoreUnreadable>().is_some(), "{error:#}");
Ok(())
}
#[test]
fn abandoned_staging_cleanup_removes_only_canonical_candidates() -> Result<()> {
let temp = tempfile::tempdir()?;
let store_path = temp.path().join("store.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
cache.put(
"primary",
&Settings {
theme: "dark".to_string(),
retries: 2,
},
)?;
// A crashed writer leaves a canonically named candidate behind.
let abandoned = temp
.path()
.join(format!("store.cc.{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&abandoned, b"partial snapshot")?;
// These only look like candidates. Cleanup must not touch them: the
// staging name is the store's own file name plus a canonical UUID, and
// anything else is somebody's file.
let lookalike = temp.path().join("store.cc.not-a-uuid.tmp");
std::fs::write(&lookalike, b"keep me")?;
let bare_suffix = temp.path().join("store.cc.tmp");
std::fs::write(&bare_suffix, b"keep me too")?;
let neighbour = temp.path().join("other.cc.00000000-0000-0000-0000-000000000000.tmp");
std::fs::write(&neighbour, b"not this store")?;
cache.put(
"primary",
&Settings {
theme: "light".to_string(),
retries: 3,
},
)?;
assert!(!abandoned.exists(), "canonical abandoned candidate survived");
assert!(lookalike.exists(), "non-UUID lookalike was deleted");
assert!(bare_suffix.exists(), "bare .tmp suffix was deleted");
assert!(neighbour.exists(), "another store's candidate was deleted");
let mut reloaded = CultCache::new();
reloaded.register_entry_type::<Settings>()?;
reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
reloaded.pull_all_backing_stores()?;
assert_eq!(reloaded.get_required::<Settings>("primary")?.theme, "light");
Ok(())
}
fn snapshot_envelope(key: &str, payload: &[u8]) -> CultCacheEnvelope {
CultCacheEnvelope {
key: key.to_string(),
r#type: "snapshot.record".to_string(),
payload: payload.to_vec(),
stored_at: "2026-01-01T00:00:00Z".to_string(),
// The store stamps the schema id from the type on write, so a
// fixture that omits it would not survive its own round trip.
schema_id: Some("snapshot.record".to_string()),
}
}
#[test]
fn snapshot_exchange_commits_only_against_the_observed_snapshot() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc"));
let first = vec![snapshot_envelope("admitted", b"one")];
assert!(store.compare_exchange_snapshot(&[], &first)?);
assert_eq!(store.pull_all_read_only_snapshot()?, first);
// A writer holding a stale view loses the race instead of clobbering it.
let stale = vec![snapshot_envelope("admitted", b"stale")];
assert!(!store.compare_exchange_snapshot(&[], &stale)?);
assert_eq!(store.pull_all_read_only_snapshot()?, first);
let second = vec![snapshot_envelope("admitted", b"two")];
assert!(store.compare_exchange_snapshot(&first, &second)?);
assert_eq!(store.pull_all_read_only_snapshot()?, second);
Ok(())
}
#[test]
fn try_snapshot_exchange_separates_a_lost_race_from_a_commit() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc"));
let first = vec![snapshot_envelope("admitted", b"one")];
assert_eq!(
store.try_compare_exchange_snapshot(&[], &first)?,
TryCompareExchangeSnapshotOutcome::Exchanged
);
assert_eq!(
store.try_compare_exchange_snapshot(&[], &first)?,
TryCompareExchangeSnapshotOutcome::Mismatch,
"a stale expectation is a lost race, not contention"
);
assert_eq!(store.pull_all_read_only_snapshot()?, first);
Ok(())
}
#[test]
fn snapshot_exchange_refuses_replacements_that_cannot_be_addressed() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc"));
assert!(
store
.compare_exchange_snapshot(&[], &[snapshot_envelope(" ", b"one")])
.is_err(),
"an unaddressable record must not reach the store"
);
assert!(
store
.compare_exchange_snapshot(
&[],
&[
snapshot_envelope("admitted", b"one"),
snapshot_envelope("admitted", b"two"),
],
)
.is_err(),
"a duplicate identity would make the snapshot ambiguous"
);
assert_eq!(store.pull_all_read_only_snapshot()?, Vec::new());
Ok(())
}
#[test]
fn per_entry_exchange_constrains_only_the_identities_it_names() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc"));
let admitted = snapshot_envelope("admitted", b"one");
let unrelated = snapshot_envelope("unrelated", b"other");
assert!(store.compare_exchange(
&[
CultCacheExpectedEnvelope {
key: "admitted".into(),
r#type: "snapshot.record".into(),
current: None,
},
CultCacheExpectedEnvelope {
key: "unrelated".into(),
r#type: "snapshot.record".into(),
current: None,
},
],
&[admitted.clone(), unrelated.clone()],
)?);
// The snapshot moved, but an expectation naming only `admitted` still
// holds. A whole-snapshot exchange would have lost this race.
let next = snapshot_envelope("admitted", b"two");
assert!(store.compare_exchange(
&[CultCacheExpectedEnvelope {
key: "admitted".into(),
r#type: "snapshot.record".into(),
current: Some(admitted.clone()),
}],
&[next.clone()],
)?);
assert_eq!(
store.pull_all_read_only_snapshot()?,
vec![next.clone(), unrelated.clone()]
);
// A stale expectation for that identity now loses.
assert!(!store.compare_exchange(
&[CultCacheExpectedEnvelope {
key: "admitted".into(),
r#type: "snapshot.record".into(),
current: Some(admitted),
}],
&[snapshot_envelope("admitted", b"three")],
)?);
assert_eq!(store.pull_all_read_only_snapshot()?, vec![next, unrelated]);
Ok(())
}
#[test]
fn per_entry_exchange_distinguishes_absent_from_stored() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc"));
let admitted = snapshot_envelope("admitted", b"one");
// Expecting a stored value where nothing is stored must not commit.
assert!(!store.compare_exchange(
&[CultCacheExpectedEnvelope {
key: "admitted".into(),
r#type: "snapshot.record".into(),
current: Some(admitted.clone()),
}],
&[admitted.clone()],
)?);
assert_eq!(store.pull_all_read_only_snapshot()?, Vec::new());
// Expecting absence commits the first writer.
assert!(store.compare_exchange(
&[CultCacheExpectedEnvelope {
key: "admitted".into(),
r#type: "snapshot.record".into(),
current: None,
}],
&[admitted.clone()],
)?);
// And the second writer expecting absence now loses, which is what
// makes this usable as a create-once claim.
assert!(!store.compare_exchange(
&[CultCacheExpectedEnvelope {
key: "admitted".into(),
r#type: "snapshot.record".into(),
current: None,
}],
&[snapshot_envelope("admitted", b"two")],
)?);
assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]);
Ok(())
}
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
fn pull_vector(name: &str) -> Result<Vec<CultCacheEnvelope>> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/document-variants-c0")
.join(name);
std::fs::copy(vector, &path)?;
SingleFileMessagePackBackingStore::new(&path).pull_all()
}
fn refusal(name: &str) -> String {
format!("{:#}", pull_vector(name).unwrap_err())
}
#[test]
fn single_file_refuses_unknown_header_by_name() {
assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9"));
}
#[test]
fn single_file_refuses_extra_record_slot_naming_the_record() {
let message = refusal("extra-slot-full-payload.msgpack");
assert!(message.contains("item:anvil"), "{message}");
assert!(message.contains(ITEM_SCHEMA_ID), "{message}");
}
#[test]
fn single_file_refuses_variant_store_by_version_or_record() {
let message = refusal("variant-v2.msgpack");
assert!(
message.contains("cultcache.store.v2") || message.contains("item:anvil-big"),
"{message}"
);
}
// The store the C# runtime wrote through its own variant API (tests/vectors/document-variants-c1).
#[test]
fn single_file_refuses_the_csharp_written_variant_store_by_version_or_record() {
let message = refusal("../document-variants-c1/variant-store.msgpack");
assert!(
message.contains("cultcache.store.v2") || message.contains("laser-big"),
"{message}"
);
}
#[test]
fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> {
let envelopes = pull_vector("v1-base.msgpack")?;
let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect();
assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]);
assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01");
assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02");
Ok(())
}
// The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced.
fn header_after_rewrite(vector: &str) -> Result<String> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors")
.join(vector);
std::fs::copy(source, &path)?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
let envelopes = store.pull_all()?;
let keys: Vec<_> = envelopes.iter().map(|e| e.key.as_str()).collect();
assert_eq!(keys, vec!["alpha", "beta"]);
store.push(&envelopes[0])?;
Ok(store_header(&std::fs::read(&path)?).first.expect("a store header"))
}
#[test]
fn a_v3_element_id_store_reads_and_a_rewrite_keeps_the_marker() -> Result<()> {
assert_eq!(
header_after_rewrite("document-variants-c2a/v3-base.msgpack")?,
"cultcache.store.v3"
);
Ok(())
}
// A whole-store flush reads the header the file carries first: one it cannot read refuses, and the file is untouched.
#[test]
fn push_all_refuses_a_store_whose_header_it_cannot_read_and_leaves_it_untouched() -> Result<()> {
for vector in [
"document-variants-c0/unknown-header.msgpack",
"document-variants-c0/variant-v2.msgpack",
"document-variants-c1/variant-store.msgpack",
] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(vector);
std::fs::copy(source, &path)?;
let before = std::fs::read(&path)?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
let error = store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()).unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{vector}: {error:#}");
assert_eq!(std::fs::read(&path)?, before, "{vector} was rewritten");
}
Ok(())
}
// The header is read by the one snapshot reader, whatever string encoding carries it.
#[test]
fn push_all_refuses_a_header_it_cannot_read_however_the_string_is_encoded() -> Result<()> {
let long = format!("cultcache.store.v9.{}", "x".repeat(90));
let mut str16 = vec![0x93, 0xda, 0x00, 18];
str16.extend_from_slice(b"cultcache.store.v9");
str16.extend_from_slice(&[0x90, 0x90]);
let mut str8 = vec![0x93, 0xd9, long.len() as u8];
str8.extend_from_slice(long.as_bytes());
str8.extend_from_slice(&[0x90, 0x90]);
for (name, bytes) in [("str16", str16), ("str8 over 90 bytes", str8)] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
std::fs::write(&path, &bytes)?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
let error = store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()).unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{name}: {error:#}");
assert_eq!(std::fs::read(&path)?, bytes, "{name} was rewritten");
}
Ok(())
}
// The writer keeps one record per key: another type under a held key is refused and the file stays; the same type replaces.
#[test]
fn a_write_of_another_type_under_an_existing_key_is_refused_and_the_same_type_replaces() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let mut store = SingleFileMessagePackBackingStore::new(&path);
let mut other = snapshot_envelope("k", b"two");
other.r#type = "snapshot.other".to_string();
other.schema_id = Some("snapshot.other".to_string());
store.push(&snapshot_envelope("k", b"one"))?;
let before = std::fs::read(&path)?;
assert!(store.push(&other).unwrap_err().downcast_ref::<SchemaConflictError>().is_some());
assert!(store.push_all(&[snapshot_envelope("k", b"one"), other.clone()], PushAllOptions::default()).is_err());
assert_eq!(std::fs::read(&path)?, before);
store.push(&snapshot_envelope("k", b"three"))?;
let read = store.pull_all()?;
assert_eq!((read.len(), read[0].payload.as_slice()), (1, b"three".as_slice()));
Ok(())
}
// A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by open,
// push_all and push alike. The bytes and every runtime's verdict are tests/vectors/document-variants-c2a/readability.
// A refusal is a CultCacheStoreUnreadable, and a replaced file carries the header its old content decides.
#[test]
fn a_file_is_replaced_exactly_when_it_reads() -> Result<()> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/document-variants-c2a/readability");
let manifest = std::fs::read_to_string(root.join("manifest.txt"))?;
let mut listed = Vec::new();
for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) {
let cells: Vec<&str> = line.split_whitespace().collect();
let (vector, reads) = (cells[0], cells[2] == "reads");
if !vector.starts_with("..") && !vector.starts_with('@') {
listed.push(vector.to_string());
}
// `@directory`: the walker puts an empty directory at the store path; it is refused and left untouched.
let directory = vector == "@directory";
let bytes = if directory { Vec::new() } else { std::fs::read(root.join(vector))? };
// A v3 store keeps its marker through a rewrite; every other file that reads is written v1.
let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 };
let operations: &[&str] = if directory {
&["open", "soft_push_all", "push_all", "push", "delete"]
} else {
&["open", "soft_push_all", "push_all", "push"]
};
for &operation in operations {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
if directory {
std::fs::create_dir(&path)?;
} else {
std::fs::write(&path, &bytes)?;
}
let mut store = SingleFileMessagePackBackingStore::new(&path);
let outcome = match operation {
"open" => store.pull_all().map(|_| ()),
"soft_push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions { soft: true }),
"push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()),
"delete" => store.delete(&snapshot_envelope("x", b"one")),
_ => store.push(&snapshot_envelope("x", b"one")),
};
let what = format!("{vector} {operation}");
assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}");
if directory {
// A directory is an I/O failure, never a typed unreadable store.
let error = outcome.unwrap_err();
assert!(io_error_in(&error).is_some() && unreadable_kind(&error).is_none(), "{what}: {error:#}");
assert!(path.is_dir() && std::fs::read_dir(&path)?.next().is_none(), "{what} touched the directory");
continue;
}
if operation == "soft_push_all" && reads {
// A soft flush leaves a store it reads byte for byte.
assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads");
continue;
}
if !reads {
let error = outcome.unwrap_err();
let expected = if vector == "header-v9.bin" {
CultCacheStoreUnreadableKind::UnsupportedFormat
} else {
CultCacheStoreUnreadableKind::Undecodable
};
assert_eq!(unreadable_kind(&error), Some(expected), "{what}: {error:#}");
assert!(io_error_in(&error).is_none(), "{what}: a refusal is not an I/O failure: {error:#}");
assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a file it cannot read");
} else if operation != "open" {
assert_eq!(store_header(&std::fs::read(&path)?).first.as_deref(), Some(header), "{what}");
assert!(store.pull_all()?.iter().any(|entry| entry.key == "x"), "{what}");
}
}
}
// Every vector in the folder has a manifest row.
let mut files: Vec<String> = std::fs::read_dir(&root)?
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.filter(|name| name.ends_with(".bin"))
.collect();
files.sort();
listed.sort();
assert_eq!(listed, files);
Ok(())
}
// An id one entry owns and a later entry lists as compatible names the entry that owns it.
#[test]
fn an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible() -> Result<()> {
for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
std::fs::copy(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/document-variants-c2a/readability")
.join(vector),
&path,
)?;
let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
assert_eq!(envelopes.len(), 2, "{vector}");
assert!(envelopes.iter().all(|envelope| envelope.r#type == "vectors.item"), "{vector}: {envelopes:?}");
}
Ok(())
}
// Records of different types under one schema id cannot be described by one catalog entry: the write is refused, typed, in
// either order, and the file is left as it was. A rename with a stable id (same type name in the new schema) writes fine.
#[test]
fn records_of_different_types_under_one_schema_id_refuse_the_write_typed() -> Result<()> {
let record = |key: &str, r#type: &str| CultCacheEnvelope {
key: key.into(),
r#type: r#type.into(),
payload: b"one".to_vec(),
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: Some("tests.x".into()),
};
for order in [vec![record("a", "tests.n"), record("b", "tests.m")], vec![record("b", "tests.m"), record("a", "tests.n")]] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let mut store = SingleFileMessagePackBackingStore::new(&path);
let error = store.push_all(&order, PushAllOptions::default()).unwrap_err();
let conflict = error.downcast_ref::<SchemaConflictError>().unwrap_or_else(|| panic!("{error:#}"));
assert_eq!(conflict.schema_id, "tests.x");
let mut names = conflict.schema_names.clone();
names.sort();
assert_eq!(names, vec!["tests.m".to_string(), "tests.n".to_string()]);
assert!(conflict.record_key == "a" || conflict.record_key == "b");
assert!(!path.exists(), "nothing is written");
}
Ok(())
}
// Another runtime's catalog entry survives this runtime's writes: a write of any record lays back, byte for byte, the entry the
// file holds for an id this runtime does not own. This one carries what a C# writer publishes (a version, a content hash,
// canonical JSON, members), none of which this runtime could describe. Whole-store writes and single pushes alike.
#[test]
fn a_write_lays_back_the_catalog_entry_the_file_holds_for_an_id_it_does_not_own() -> Result<()> {
let held = PersistedSchemaCatalogEntry(
"sha256:csharp".into(),
"tests.note".into(),
"tests.note_v7".into(),
"sha256:content".into(),
"{\"schemaName\":\"tests.note\"}".into(),
vec!["sha256:csharp".into()],
vec![PersistedSchemaCatalogMember(0, "Title".into(), "System.String".into(), false, false, None, true, Some("title".into()))],
);
let own = CultCacheEnvelope {
key: "b".into(),
r#type: "tests.other".into(),
payload: b"two".to_vec(),
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: Some("tests.other".into()),
};
// An older entry that lists the id: the entry that owns the id is the one laid back.
let lister = PersistedSchemaCatalogEntry(
"sha256:older".into(),
"tests.note".into(),
"tests.note_v6".into(),
"sha256:older".into(),
String::new(),
vec!["sha256:older".into(), "sha256:csharp".into()],
Vec::new(),
);
for whole_store in [false, true] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let record = PersistedRecord("a".into(), "sha256:csharp".into(), "2026-09-30T00:00:00Z".into(), b"one".to_vec());
std::fs::write(
&path,
rmp_serde::to_vec(&PersistedStoreSnapshot("cultcache.store.v1".into(), vec![lister.clone(), held.clone()], vec![record]))?,
)?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
if whole_store {
// As a Rust cache holds it after a pull: under the registered type that reads the schema by name.
let mut entries = store.pull_all()?;
for entry in &mut entries {
entry.r#type = "tests.rust_note".into();
}
entries.push(own.clone());
store.push_all(&entries, PushAllOptions::default())?;
} else {
store.push(&own)?;
}
let written: PersistedStoreSnapshot = rmp_serde::from_slice(&std::fs::read(&path)?)?;
let entry = written.1.iter().find(|entry| entry.0 == "sha256:csharp").expect("the entry for the id is written");
assert_eq!(rmp_serde::to_vec(entry)?, rmp_serde::to_vec(&held)?, "whole store: {whole_store}");
assert!(written.1.iter().any(|entry| entry.0 == "tests.other"), "its own id is described");
}
Ok(())
}
// Store read and write faults: what a consumer can tell apart, and what a failed
// write leaves on disk.
fn unreadable_kind(error: &anyhow::Error) -> Option<CultCacheStoreUnreadableKind> {
error
.downcast_ref::<CultCacheStoreUnreadable>()
.map(|unreadable| unreadable.kind)
}
fn io_error_in(error: &anyhow::Error) -> Option<&std::io::Error> {
error.chain().find_map(|cause| cause.downcast_ref::<std::io::Error>())
}
/// A store of three records written by this runtime, returned with its bytes.
fn written_store(path: &Path) -> Result<(SingleFileMessagePackBackingStore, Vec<u8>)> {
let store = SingleFileMessagePackBackingStore::new(path);
let records = vec![
snapshot_envelope("alpha", &[1; 64]),
snapshot_envelope("beta", &[2; 64]),
snapshot_envelope("gamma", &[3; 64]),
];
assert!(store.compare_exchange_snapshot(&[], &records)?);
let bytes = fs::read(path)?;
Ok((store, bytes))
}
#[test]
fn a_truncated_store_is_undecodable_on_every_read_and_is_left_as_it_was() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (store, bytes) = written_store(&path)?;
let truncated = &bytes[..bytes.len() / 2];
fs::write(&path, truncated)?;
let failures = [
store.pull_all().unwrap_err(),
store.pull_all_read_only_snapshot().unwrap_err(),
store
.compare_exchange_snapshot(&[], &[snapshot_envelope("delta", b"4")])
.unwrap_err(),
];
for error in &failures {
assert_eq!(unreadable_kind(error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}");
assert_eq!(error.downcast_ref::<CultCacheStoreUnreadable>().unwrap().path, path);
assert!(error.to_string().contains(&path.display().to_string()), "{error}");
assert!(io_error_in(error).is_none(), "{error:#}");
}
assert_eq!(fs::read(&path)?, truncated);
Ok(())
}
#[test]
fn bytes_that_are_not_a_store_are_undecodable() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let store = SingleFileMessagePackBackingStore::new(&path);
// The last opens like a store, with a string where the header goes, but only a
// `cultcache.store.*` header claims a format this runtime might not read.
for bytes in [
&b"not a store at all"[..],
&[0x92, 0xa3, b'a', b'b'][..],
&[0xc1][..],
&[0x92, 0xa2, b'a', b'b', 0x90][..],
&[0x93, 0xb0, b'c', b'u', b'l', b't', b'c', b'a', b'c', b'h', b'e', b'.', b's', b't', b'o', b'r', b'e', b'X', 0x90, 0x90][..],
] {
fs::write(&path, bytes)?;
let error = store.pull_all().unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{bytes:?}: {error:#}");
}
Ok(())
}
#[test]
fn a_v1_store_holding_a_record_v1_does_not_allow_is_undecodable() {
let error = pull_vector("extra-slot-full-payload.msgpack").unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}");
}
#[test]
fn a_store_in_a_format_this_runtime_does_not_read_is_unsupported_even_when_cut_short() -> Result<()> {
for name in ["unknown-header.msgpack", "variant-v2.msgpack", "../document-variants-c1/variant-store.msgpack"] {
let error = pull_vector(name).unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{name}: {error:#}");
}
// The header is all this runtime judges: a newer store cut short is still a
// newer store, not a damaged one this runtime could replace.
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let vector = fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/document-variants-c0/unknown-header.msgpack"),
)?;
let truncated = &vector[..vector.len() / 2];
fs::write(&path, truncated)?;
let store = SingleFileMessagePackBackingStore::new(&path);
for error in [store.pull_all().unwrap_err(), store.pull_all_read_only_snapshot().unwrap_err()] {
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{error:#}");
assert!(format!("{error:#}").contains("cultcache.store.v9"), "{error:#}");
assert!(io_error_in(&error).is_none(), "{error:#}");
}
assert_eq!(fs::read(&path)?, truncated);
Ok(())
}
#[test]
fn a_store_that_cannot_be_read_is_an_io_error_and_never_unreadable() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
fs::create_dir(&path)?;
let store = SingleFileMessagePackBackingStore::new(&path);
for error in [store.pull_all().unwrap_err(), store.pull_all_read_only_snapshot().unwrap_err()] {
assert!(io_error_in(&error).is_some(), "{error:#}");
assert_eq!(unreadable_kind(&error), None, "{error:#}");
}
Ok(())
}
/// Runs one write in a child process whose file-size limit is far below the
/// snapshot, with SIGXFSZ ignored so the write fails with EFBIG instead of
/// killing the child: a real mid-write failure after the staging file exists,
/// reached without permissions (the verification container runs as root).
#[cfg(unix)]
#[test]
fn a_write_that_fails_part_way_leaves_no_staging_file_and_keeps_the_store() -> Result<()> {
const CHILD_STORE: &str = "CULTCACHE_RS_FILE_SIZE_LIMITED_STORE";
if let Ok(path) = std::env::var(CHILD_STORE) {
let mut store = SingleFileMessagePackBackingStore::new(path);
let error = store
.push(&snapshot_envelope("large", &vec![7; 1 << 20]))
.unwrap_err();
let io = io_error_in(&error).expect("a size-limited write fails with an io::Error");
assert_eq!(io.kind(), std::io::ErrorKind::FileTooLarge, "{error:#}");
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}");
assert_eq!(unreadable_kind(&error), None, "{error:#}");
return Ok(());
}
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (store, before) = written_store(&path)?;
let child = std::process::Command::new("sh")
.arg("-c")
.arg("trap '' XFSZ; ulimit -f 64; exec \"$0\" --exact --test-threads=1 --quiet tests::a_write_that_fails_part_way_leaves_no_staging_file_and_keeps_the_store")
.arg(std::env::current_exe()?)
.env(CHILD_STORE, &path)
.output()?;
assert!(
child.status.success(),
"the size-limited child did not fail its write as expected: {}
{}{}",
child.status,
String::from_utf8_lossy(&child.stdout),
String::from_utf8_lossy(&child.stderr)
);
let staging: Vec<_> = fs::read_dir(temp.path())?
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
.collect::<std::io::Result<Vec<_>>>()?
.into_iter()
.filter(|name| name.starts_with("store.cc.") && name.ends_with(".tmp"))
.collect();
assert_eq!(staging, Vec::<String>::new());
assert_eq!(fs::read(&path)?, before);
assert_eq!(store.pull_all()?.len(), 3);
Ok(())
}
/// Every read of a single-file store: the locked pull, the lock-free provider
/// snapshot, and the exchange a writer reads through.
fn every_read(store: &SingleFileMessagePackBackingStore) -> [Result<Vec<CultCacheEnvelope>>; 3] {
[
store.pull_all(),
store.pull_all_read_only_snapshot(),
store
.compare_exchange_snapshot(&[], &[snapshot_envelope("delta", b"4")])
.map(|_| Vec::new()),
]
}
fn assert_io_failure_on_every_read(store: &SingleFileMessagePackBackingStore) {
for result in every_read(store) {
let error = result.expect_err("a store that cannot be reached must not read as empty");
let io = io_error_in(&error).expect("the failure to reach the store is an io::Error");
assert_ne!(io.kind(), std::io::ErrorKind::NotFound, "{error:#}");
assert_eq!(unreadable_kind(&error), None, "{error:#}");
}
}
#[test]
fn only_a_missing_store_reads_as_empty() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let store = SingleFileMessagePackBackingStore::new(&path);
assert!(store.pull_all()?.is_empty());
assert!(store.pull_all_read_only_snapshot()?.is_empty());
assert!(store.compare_exchange_snapshot(&[], &[snapshot_envelope("delta", b"4")])?);
// A store whose lock file is gone is still a store, not an absent one.
fs::remove_file(store.lock_path())?;
assert_eq!(store.pull_all()?, vec![snapshot_envelope("delta", b"4")]);
Ok(())
}
#[cfg(unix)]
#[test]
fn a_store_behind_a_symlink_loop_is_an_io_error_on_every_read() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
std::os::unix::fs::symlink(&path, &path)?;
assert_io_failure_on_every_read(&SingleFileMessagePackBackingStore::new(&path));
assert!(fs::symlink_metadata(&path)?.file_type().is_symlink());
Ok(())
}
#[cfg(unix)]
#[test]
fn a_store_under_a_file_instead_of_a_directory_is_an_io_error_on_every_read() -> Result<()> {
let temp = tempfile::tempdir()?;
let parent = temp.path().join("body");
fs::write(&parent, b"not a directory")?;
assert_io_failure_on_every_read(&SingleFileMessagePackBackingStore::new(parent.join("store.cc")));
assert_eq!(fs::read(&parent)?, b"not a directory");
Ok(())
}
#[test]
fn an_existing_empty_file_is_undecodable_and_is_left_as_it_was() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
fs::write(&path, b"")?;
let store = SingleFileMessagePackBackingStore::new(&path);
for result in every_read(&store) {
let error = result.expect_err("no CultCache writer leaves an empty file, so it is not a store");
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}");
assert!(io_error_in(&error).is_none(), "{error:#}");
}
assert_eq!(fs::read(&path)?, b"");
// An empty array is the legacy envelope array with no envelopes: it has no
// header, so it claims no format, and it is an empty store.
fs::write(&path, [0x90])?;
assert!(store.pull_all()?.is_empty());
assert!(store.pull_all_read_only_snapshot()?.is_empty());
Ok(())
}
#[test]
fn pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical() -> Result<()> {
// SYNTHETIC pre-v1 vector: no real file of this shape is known; the Rust envelope payload is binary.
let temp = tempfile::tempdir()?;
let path = temp.path().join("legacy.cc");
let legacy = rmp_serde::to_vec_named(&vec![CultCacheEnvelope {
key: "note:hello".to_string(),
r#type: "note".to_string(),
payload: vec![0x90],
stored_at: "2026-10-20T00:00:00Z".to_string(),
schema_id: None,
}])?;
fs::write(&path, &legacy)?;
let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
assert_eq!(envelopes.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["note:hello"]);
assert_eq!(fs::read(&path)?, legacy);
Ok(())
}
#[test]
fn a_newer_store_is_named_once_its_header_string_is_complete() -> Result<()> {
let vector = fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/document-variants-c0/unknown-header.msgpack"),
)?;
// One byte of array header, one of string header, then the 18-byte header string.
let header = "cultcache.store.v9";
let header_end = 2 + header.len();
assert_eq!(header_end, 20);
assert_eq!(&vector[2..header_end], header.as_bytes());
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let store = SingleFileMessagePackBackingStore::new(&path);
for length in 1..=vector.len() {
fs::write(&path, &vector[..length])?;
let error = store.pull_all().unwrap_err();
let expected = if length < header_end {
CultCacheStoreUnreadableKind::Undecodable
} else {
CultCacheStoreUnreadableKind::UnsupportedFormat
};
assert_eq!(unreadable_kind(&error), Some(expected), "cut to {length}: {error:#}");
}
Ok(())
}
fn store_file(path: &std::path::Path, catalog: &[(&str, &str)], records: &[(&str, &str, Vec<u8>)]) -> Result<()> {
let snapshot = PersistedStoreSnapshot(
"cultcache.store.v1".to_string(),
catalog
.iter()
.map(|(id, name)| {
PersistedSchemaCatalogEntry(
id.to_string(),
name.to_string(),
format!("{name}.v1"),
id.to_string(),
String::new(),
vec![id.to_string()],
Vec::new(),
)
})
.collect(),
records
.iter()
.map(|(key, id, payload)| {
PersistedRecord(key.to_string(), id.to_string(), "2026-09-30T00:00:00Z".to_string(), payload.clone())
})
.collect(),
);
std::fs::write(path, rmp_serde::to_vec(&snapshot)?)?;
Ok(())
}
// A schema renamed under a stable id: a registered type owns its id (its id is its entry type), so a write of it onto a file
// whose entry carries the old name is not a conflict. Every record under the id, the file's and the write's, is then described
// by the registered name. A write of another type under that id retypes what the file holds, and is refused.
#[test]
fn a_schema_renamed_under_a_stable_id_is_written_under_the_registered_name() -> Result<()> {
let envelope = |r#type: &str, schema_id: &str| CultCacheEnvelope {
key: "b".into(),
r#type: r#type.into(),
payload: b"two".to_vec(),
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: Some(schema_id.into()),
};
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?;
SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.s", "t.s"))?;
let mut envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
envelopes.sort_by(|left, right| left.key.cmp(&right.key));
assert_eq!(
envelopes.iter().map(|entry| (entry.key.as_str(), entry.r#type.as_str())).collect::<Vec<_>>(),
vec![("a", "t.s"), ("b", "t.s")]
);
store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?;
let before = std::fs::read(&path)?;
let error = SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.new", "t.s")).unwrap_err();
let conflict = error.downcast_ref::<SchemaConflictError>().unwrap_or_else(|| panic!("{error:#}"));
assert_eq!(conflict.schema_id, "t.s");
assert_eq!(conflict.record_key, "b");
assert_eq!(std::fs::read(&path)?, before, "the file is left as it was");
Ok(())
}
// The retype refusal asks what C# WrittenOwns asks: does the written record own the schema id the held one is stored under.
// A type renamed under a stable id owns it, so its write replaces the record; another id does not, and is refused.
#[test]
fn a_write_owning_the_stored_schema_id_replaces_the_record_under_a_new_name() -> Result<()> {
let envelope = |r#type: &str, schema_id: &str, payload: &[u8]| CultCacheEnvelope {
key: "a".into(),
r#type: r#type.into(),
payload: payload.to_vec(),
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: Some(schema_id.into()),
};
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?;
let before = std::fs::read(&path)?;
let error = SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.other", "t.other", b"x")).unwrap_err();
assert_eq!(retype_refusal(&error).record_key, "a");
assert_eq!(std::fs::read(&path)?, before);
SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.s", "t.s", b"two"))?;
let held = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
assert_eq!(held.len(), 1);
assert_eq!((held[0].r#type.as_str(), held[0].payload.as_slice()), ("t.s", b"two".as_slice()));
Ok(())
}
// Where a held record and a written one share a schema id under different type names, the written one is the record
// whichever name sorts first: the writers hand settle_one_record_per_key their entries sorted by type name.
#[test]
fn a_renamed_type_replaces_the_held_record_whether_its_name_sorts_before_or_after() -> Result<()> {
for new_name in ["a.new", "z.new"] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?;
let written = CultCacheEnvelope {
key: "a".into(),
r#type: new_name.into(),
payload: b"two".to_vec(),
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: Some("t.s".into()),
};
SingleFileMessagePackBackingStore::new(&path).push(&written)?;
let held = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
assert_eq!(held.len(), 1, "{new_name}");
assert_eq!(held[0].payload, b"two".to_vec(), "{new_name}");
}
Ok(())
}
// In one push_all, entries under a key and a schema id with different type names resolve by the order given, not by
// the order of the names.
#[test]
fn push_all_resolves_same_id_entries_with_different_names_by_the_order_given() -> Result<()> {
let envelope = |r#type: &str, payload: &[u8]| CultCacheEnvelope {
key: "a".into(),
r#type: r#type.into(),
payload: payload.to_vec(),
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: Some("t.s".into()),
};
for (first, second) in [("a.name", "b.name"), ("b.name", "a.name")] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let mut store = SingleFileMessagePackBackingStore::new(&path);
store.push_all(&[envelope(first, b"first"), envelope(second, b"second")], PushAllOptions::default())?;
let held = store.pull_all()?;
assert_eq!(held.len(), 1, "{first} then {second}");
assert_eq!(held[0].payload, b"second".to_vec(), "{first} then {second}");
}
Ok(())
}
// Of same-type entries sharing a key in one push_all the last one given is the record.
#[test]
fn push_all_keeps_the_last_of_two_same_type_entries_under_a_key() -> Result<()> {
let mut cache = CultCache::new();
cache.register_registry(TestEntries)?;
let (first, _) = cache.prepare_entry("k", &settings_one())?;
let (last, _) = cache.prepare_entry("k", &Settings { theme: "red".to_string(), retries: 2 })?;
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let mut store = SingleFileMessagePackBackingStore::new(&path);
store.push_all(&[first, last.clone()], PushAllOptions::default())?;
assert_eq!(store.pull_all()?.into_iter().map(|e| e.payload).collect::<Vec<_>>(), vec![last.payload]);
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.stable", schema = "tests.old_name")]
struct StableId {
#[cultcache(key = 0)]
name: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.other", schema = "tests.other_name")]
struct OtherId {
#[cultcache(key = 0)]
name: String,
}
// A record resolves to a registered type by its schema id, never by the name its catalog entry carries: a schema renamed under a
// stable id opens, and an id that names one type is not overruled by a name that names another.
#[test]
fn a_schema_renamed_under_a_stable_id_opens_by_its_id() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
store_file(&path, &[("tests.stable", "tests.renamed")], &[("a", "tests.stable", rmp_serde::to_vec(&StableId { name: "a".into() })?)])?;
let mut cache = CultCache::new();
cache.register_entry_type::<StableId>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
assert_eq!(cache.get_required::<StableId>("a")?.name, "a");
Ok(())
}
#[test]
fn a_schema_id_that_names_one_type_is_not_overruled_by_a_name_that_names_another() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
// The entry that publishes the id carries the other type's name.
store_file(&path, &[("tests.other", "tests.stable")], &[("k", "tests.other", rmp_serde::to_vec(&OtherId { name: "k".into() })?)])?;
let mut cache = CultCache::new();
cache.register_entry_type::<StableId>()?;
cache.register_entry_type::<OtherId>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
assert_eq!(cache.get_required::<OtherId>("k")?.name, "k");
assert!(cache.get::<StableId>("k")?.is_none());
Ok(())
}
// A store cut short is refused whatever its version, and left as it is.
#[test]
fn a_truncated_v1_or_v2_store_is_refused_on_open_and_rewrite() -> Result<()> {
for vector in [
"document-variants-c0/v1-base.msgpack",
"document-variants-c0/variant-v2.msgpack",
"document-variants-c1/variant-store.msgpack",
] {
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(vector);
let whole = std::fs::read(source)?;
let cut = &whole[..whole.len() - 1];
for operation in ["open", "push_all"] {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
std::fs::write(&path, cut)?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
let outcome = match operation {
"open" => store.pull_all().map(|_| ()),
_ => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()),
};
let error = outcome.expect_err(&format!("{vector} {operation}"));
assert!(unreadable_kind(&error).is_some(), "{vector} {operation}: {error:#}");
assert_eq!(std::fs::read(&path)?, cut, "{vector} {operation} rewrote a truncated store");
}
}
Ok(())
}
#[test]
fn refusals_say_what_was_found_without_blaming_variants() {
let unsupported = format!("{:#}", pull_vector("unknown-header.msgpack").unwrap_err());
assert!(unsupported.contains("\"cultcache.store.v9\""), "{unsupported}");
assert!(unsupported.contains("does not read"), "{unsupported}");
assert!(!unsupported.contains("variant"), "{unsupported}");
// The C# reference refuses the same bytes: a v1 store with a longer record is
// not a store any runtime reads, so no upgrade is suggested.
let invalid = format!("{:#}", pull_vector("extra-slot-full-payload.msgpack").unwrap_err());
assert!(invalid.contains("not a valid store"), "{invalid}");
assert!(!invalid.contains("variant"), "{invalid}");
assert!(!invalid.contains("runtime that"), "{invalid}");
}
/// The store path is a directory, so the one reader cannot reach a store there: the write stops at the read, as an
/// I/O error and not a write failure, before any staging file exists.
#[test]
fn a_write_over_a_directory_stops_at_the_read_and_leaves_no_staging_file() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
fs::create_dir(&path)?;
fs::write(path.join("kept"), b"kept")?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
let error = store
.push_all(&[snapshot_envelope("alpha", b"1")], PushAllOptions::default())
.unwrap_err();
assert!(io_error_in(&error).is_some() && write_failure_kind(&error).is_none(), "{error:#}");
let staging: Vec<_> = fs::read_dir(temp.path())?
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
.collect::<std::io::Result<Vec<_>>>()?
.into_iter()
.filter(|name| name.starts_with("store.cc.") && name.ends_with(".tmp"))
.collect();
assert_eq!(staging, Vec::<String>::new());
assert_eq!(fs::read(path.join("kept"))?, b"kept");
Ok(())
}
/// MessagePack bytes of a fixstr, for store fixtures built by hand.
fn fixstr(text: &str) -> Vec<u8> {
assert!(text.len() < 32);
let mut bytes = vec![0xa0 | text.len() as u8];
bytes.extend_from_slice(text.as_bytes());
bytes
}
#[test]
fn a_refusal_never_echoes_a_value_from_the_store() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let store = SingleFileMessagePackBackingStore::new(&path);
// A v1 store whose catalog holds a string where an entry goes, and a legacy
// envelope array holding a string where an envelope goes: the decoder's own
// message would quote both strings.
let mut v1 = vec![0x93];
v1.extend(fixstr(STORE_FORMAT_V1));
v1.push(0x91);
v1.extend(fixstr("SECRET-CATALOG-TEXT"));
v1.push(0x90);
let mut legacy = vec![0x91];
legacy.extend(fixstr("SECRET-LEGACY-TEXT"));
for bytes in [v1, legacy] {
fs::write(&path, &bytes)?;
for result in every_read(&store) {
let error = result.unwrap_err();
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}");
assert!(!format!("{error:#}").contains("SECRET"), "{error:#}");
assert!(!format!("{error:?}").contains("SECRET"), "{error:?}");
}
}
Ok(())
}
#[test]
fn a_rewrite_of_a_v1_store_stays_v1() -> Result<()> {
assert_eq!(
header_after_rewrite("document-variants-c0/v1-base.msgpack")?,
"cultcache.store.v1"
);
Ok(())
}
#[test]
fn a_header_is_echoed_only_in_the_known_shape() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let store = SingleFileMessagePackBackingStore::new(&path);
for (header, echoed) in [
("cultcache.store.v12", true),
("cultcache.store.SECRET-HEADER", false),
("cultcache.store.v12SECRET", false),
("cultcache.store.v", false),
// Digits are ASCII only: Arabic-Indic digits are not the known shape.
("cultcache.store.v١٢", false),
] {
let mut bytes = vec![0x93];
bytes.extend(fixstr(header));
bytes.extend([0x90, 0x90]);
fs::write(&path, &bytes)?;
let error = store.pull_all().unwrap_err();
let text = format!("{error:#}");
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{text}");
// The refusal names every format this runtime reads.
assert!(text.contains("\"cultcache.store.v1\""), "{text}");
assert!(text.contains("\"cultcache.store.v3\""), "{text}");
if echoed {
assert!(text.contains(&format!("\"{header}\"")), "{text}");
} else {
assert!(!text.contains(&format!("\"{header}\"")), "{text}");
assert!(!text.contains("SECRET"), "{text}");
assert!(text.contains(&format!("of {} bytes", header.len())), "{text}");
}
}
Ok(())
}
/// Nothing at the store path is an empty store; a symbolic link whose target is
/// gone is something, and a writer must not rename a file over it.
#[cfg(unix)]
#[test]
fn a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written() -> Result<()> {
let temp = tempfile::tempdir()?;
let volume = temp.path().join("volume");
fs::create_dir(&volume)?;
let target = volume.join("store.cc");
let path = temp.path().join("store.cc");
std::os::unix::fs::symlink(&target, &path)?;
let store = SingleFileMessagePackBackingStore::new(&path);
for result in every_read(&store) {
let error = result.expect_err("a dangling link must not read as an empty store");
assert!(io_error_in(&error).is_some(), "{error:#}");
assert_eq!(unreadable_kind(&error), None, "{error:#}");
}
// Every merging writer refuses on its own read, so none can replace the link.
let mut writer = SingleFileMessagePackBackingStore::new(&path);
let delta = snapshot_envelope("delta", b"4");
let writes = [
writer.push(&delta),
writer.delete(&delta),
writer.insert_entry_if_absent(delta.clone()).map(|_| ()),
writer.compare_and_swap_batch(&[], vec![delta.clone()]).map(|_| ()),
writer.append_if_snapshot_unchanged(&[], vec![delta.clone()]).map(|_| ()),
];
for (index, result) in writes.into_iter().enumerate() {
let error = result.expect_err("a merging write through a dangling link must refuse");
assert!(io_error_in(&error).is_some(), "write {index}: {error:#}");
assert_eq!(write_failure_kind(&error), None, "write {index}: {error:#}");
}
assert!(fs::symlink_metadata(&path)?.file_type().is_symlink());
assert!(!target.exists());
assert_eq!(fs::read_dir(&volume)?.count(), 0);
Ok(())
}
fn write_failure_kind(error: &anyhow::Error) -> Option<CultCacheStoreWriteFailedKind> {
error
.downcast_ref::<CultCacheStoreWriteFailed>()
.map(|failed| failed.kind)
}
/// Runs `write` with `step` made to fail on this thread.
fn with_write_fault<T>(step: WriteStep, write: impl FnOnce() -> T) -> T {
INJECTED_WRITE_FAULT.with(|fault| fault.set(Some(step)));
let result = write();
INJECTED_WRITE_FAULT.with(|fault| fault.set(None));
result
}
#[test]
fn a_write_that_fails_before_the_rename_is_not_replaced_and_the_store_is_intact() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (store, before) = written_store(&path)?;
let error = with_write_fault(WriteStep::StagingSync, || {
store.compare_exchange_snapshot(&store.pull_all().unwrap(), &[snapshot_envelope("delta", b"4")])
})
.unwrap_err();
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}");
assert!(io_error_in(&error).is_some(), "{error:#}");
assert!(error.to_string().contains(&format!("{} was not replaced", path.display())), "{error}");
assert_eq!(fs::read(&path)?, before);
assert_eq!(fs::read_dir(temp.path())?.count(), 2, "only the store and its lock remain");
// A store path that is a directory is refused at the read: an I/O error, not a write failure.
let blocked = temp.path().join("blocked.cc");
fs::create_dir(&blocked)?;
let mut blocked_store = SingleFileMessagePackBackingStore::new(&blocked);
let error = blocked_store
.push_all(&[snapshot_envelope("alpha", b"1")], PushAllOptions::default())
.unwrap_err();
assert!(io_error_in(&error).is_some() && write_failure_kind(&error).is_none(), "{error:#}");
assert!(fs::metadata(&blocked)?.is_dir());
Ok(())
}
#[test]
fn a_write_whose_rename_fails_is_not_replaced_and_leaves_no_staging_file() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (mut store, before) = written_store(&path)?;
let replacement = [snapshot_envelope("delta", b"4")];
let error = with_write_fault(WriteStep::Rename, || {
store.compare_exchange_snapshot(&store.pull_all().unwrap(), &replacement)
})
.expect_err("a rename that failed is not a write that landed");
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}");
assert!(io_error_in(&error).is_some(), "{error:#}");
assert_eq!(fs::read(&path)?, before, "the store is as it was");
assert_eq!(fs::read_dir(temp.path())?.count(), 2, "only the store and its lock remain: no staging file");
let error = with_write_fault(WriteStep::Rename, || store.push(&replacement[0]))
.expect_err("a push whose rename failed is not a push that landed");
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}");
assert_eq!(fs::read(&path)?, before);
assert_eq!(fs::read_dir(temp.path())?.count(), 2, "only the store and its lock remain: no staging file");
// The failure left nothing behind that stops the next write.
store.push(&replacement[0])?;
assert!(store.pull_all()?.contains(&replacement[0]));
Ok(())
}
#[cfg(unix)]
#[test]
fn a_write_whose_directory_sync_fails_is_replaced_but_not_durable() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (store, _) = written_store(&path)?;
let replacement = vec![snapshot_envelope("delta", b"4")];
let error = with_write_fault(WriteStep::DirectorySync, || {
store.compare_exchange_snapshot(&store.pull_all().unwrap(), &replacement)
})
.unwrap_err();
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::ReplacedNotDurable), "{error:#}");
assert!(error.to_string().contains(&format!("{} was replaced, but the replacement is not yet durable", path.display())), "{error}");
assert!(io_error_in(&error).is_some(), "{error:#}");
assert_eq!(store.pull_all()?, replacement);
Ok(())
}
#[test]
fn a_write_failure_names_no_value_from_the_snapshot() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let store = SingleFileMessagePackBackingStore::new(&path);
let error = with_write_fault(WriteStep::StagingSync, || {
store.compare_exchange_snapshot(&[], &[snapshot_envelope("SECRET-KEY", b"SECRET-PAYLOAD")])
})
.unwrap_err();
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}");
assert!(!format!("{error:#}").contains("SECRET"), "{error:#}");
Ok(())
}
#[test]
fn a_write_that_landed_is_reported_as_landed_when_the_unlock_after_it_fails() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (mut store, _) = written_store(&path)?;
let replacement = vec![snapshot_envelope("delta", b"4")];
let exchanged = with_write_fault(WriteStep::Unlock, || {
store.compare_exchange_snapshot(&store.pull_all().unwrap(), &replacement)
})?;
assert!(exchanged);
assert_eq!(store.pull_all()?, replacement);
let pushed = snapshot_envelope("epsilon", b"5");
with_write_fault(WriteStep::Unlock, || store.push(&pushed))?;
assert!(with_write_fault(WriteStep::Unlock, || store.pull_all())?.contains(&pushed));
// The lock was released: another writer takes it at once.
assert!(matches!(
store.try_compare_exchange_snapshot(&store.pull_all()?, &replacement)?,
TryCompareExchangeSnapshotOutcome::Exchanged
));
// The keyed store releases its lock the same way.
let mut keyed = RedbMessagePackBackingStore::new(temp.path().join("keyed.redb"))?;
with_write_fault(WriteStep::Unlock, || keyed.push(&pushed))?;
assert_eq!(keyed.pull_all()?, vec![pushed]);
Ok(())
}
// What a write does through a symbolic link today. R3 decides whether a write
// resolves the link or refuses it; until then these pin the current behaviour so the
// change is deliberate. Reads refuse a dangling link (see the test above).
#[cfg(unix)]
#[test]
fn r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link() -> Result<()> {
let temp = tempfile::tempdir()?;
let volume = temp.path().join("volume");
fs::create_dir(&volume)?;
let target = volume.join("store.cc");
SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?;
let before = fs::read(&target)?;
let link = temp.path().join("store.cc");
std::os::unix::fs::symlink(&target, &link)?;
SingleFileMessagePackBackingStore::new(&link).push(&snapshot_envelope("b", b"2"))?;
assert!(fs::symlink_metadata(&link)?.file_type().is_file(), "the link was replaced by a regular file");
assert_eq!(fs::read(&target)?, before, "the store the link pointed to is unchanged");
Ok(())
}
// The two cases the vectors cannot hold: nothing at the path is written, and a dangling link is refused and left as it is.
#[test]
fn a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
SingleFileMessagePackBackingStore::new(&path).push_all(&[snapshot_envelope("a", b"1")], PushAllOptions { soft: true })?;
let seeded = fs::read(&path)?;
let mut store = SingleFileMessagePackBackingStore::new(&path);
assert_eq!(store.pull_all()?.len(), 1);
store.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions { soft: true })?;
assert_eq!(fs::read(&path)?, seeded, "a soft flush over a store it reads wrote");
#[cfg(unix)]
{
let volume = temp.path().join("volume");
fs::create_dir(&volume)?;
let link = temp.path().join("dangling.cc");
std::os::unix::fs::symlink(volume.join("store.cc"), &link)?;
let error = SingleFileMessagePackBackingStore::new(&link)
.push_all(&[snapshot_envelope("a", b"1")], PushAllOptions { soft: true })
.unwrap_err();
assert!(io_not_found(&error), "{error:#}");
assert!(fs::symlink_metadata(&link)?.file_type().is_symlink(), "the link was replaced");
assert_eq!(fs::read_dir(&volume)?.count(), 0, "nothing reached the link's volume");
}
Ok(())
}
#[cfg(unix)]
#[test]
fn r3_decides_resolve_or_refuse_a_push_all_through_a_dangling_link_replaces_the_link() -> Result<()> {
let temp = tempfile::tempdir()?;
let volume = temp.path().join("volume");
fs::create_dir(&volume)?;
let link = temp.path().join("store.cc");
std::os::unix::fs::symlink(volume.join("store.cc"), &link)?;
SingleFileMessagePackBackingStore::new(&link).push_all(&[snapshot_envelope("a", b"1")], PushAllOptions::default())?;
assert!(fs::symlink_metadata(&link)?.file_type().is_file(), "the link was replaced by a regular file");
assert_eq!(fs::read_dir(&volume)?.count(), 0, "nothing reached the link's volume");
Ok(())
}
#[test]
fn a_keyed_store_refusal_never_echoes_a_stored_value() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("keyed.redb");
let envelope = snapshot_envelope("k", b"1");
RedbMessagePackBackingStore::new(&path)?.push(&envelope)?;
{
let database = redb::Database::create(&path)?;
let write = database.begin_write()?;
{
let mut table = write.open_table(REDB_ENVELOPES)?;
let keys: Vec<Vec<u8>> = table
.iter()?
.map(|row| row.map(|(key, _)| key.value().to_vec()))
.collect::<std::result::Result<_, _>>()?;
let mut value = vec![0xab];
value.extend_from_slice(b"SECRET-REDB");
for key in keys {
table.insert(key.as_slice(), value.as_slice())?;
}
}
write.commit()?;
}
let store = RedbMessagePackBackingStore::new(&path)?;
for error in [store.pull_all().unwrap_err(), store.compare_and_swap_entry(&envelope, snapshot_envelope("k", b"2")).unwrap_err()] {
assert!(format!("{error:#}").contains("failed to decode redb CultCache envelope"), "{error:#}");
assert!(!format!("{error:#}").contains("SECRET"), "{error:#}");
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
enum Choice {
Only,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct SkippedChoice {
#[serde(default, skip_serializing_if = "Option::is_none")]
choice: Option<Choice>,
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "skipped-choice")]
struct ChoiceNote {
#[cultcache(key = 0)]
value: SkippedChoice,
}
#[test]
fn a_document_decode_refusal_names_the_entry_and_never_a_stored_value() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
// A compact SkippedChoice with no choice is [text], so decoding it reads the text
// where the enum goes, and serde's own message would quote it as an unknown variant.
let secret = ChoiceNote {
value: SkippedChoice {
choice: None,
text: "SECRET-CHOICE".into(),
},
};
let mut payload = vec![0x91, 0x91, 0xad];
payload.extend_from_slice(b"SECRET-CHOICE");
let malformed = CultCacheEnvelope {
key: "k".into(),
r#type: <ChoiceNote as DatabaseEntry>::TYPE.into(),
payload,
stored_at: "2026-09-30T00:00:00Z".into(),
schema_id: None,
};
SingleFileMessagePackBackingStore::new(&path).push(&malformed)?;
let mut cache = CultCache::new();
cache.register_entry_type::<ChoiceNote>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?;
cache.pull_all_backing_stores()?;
let refusals = [
("get", cache.get::<ChoiceNote>("k").unwrap_err()),
("get_all", cache.get_all::<ChoiceNote>().unwrap_err()),
("get_all_with_keys", cache.get_all_with_keys::<ChoiceNote>().unwrap_err()),
("put_envelope", cache.put_envelope::<ChoiceNote>(malformed.clone()).unwrap_err()),
("prepare_entry", cache.prepare_entry("compact", &secret).unwrap_err()),
];
for (path, error) in refusals {
let text = format!("{error:#}");
assert!(!text.contains("SECRET"), "{path}: {text}");
assert!(text.contains("CultCache"), "{path}: {text}");
}
Ok(())
}
#[test]
fn a_snapshot_the_store_cannot_hold_is_rejected_before_any_write() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.cc");
let (store, before) = written_store(&path)?;
let mut first = snapshot_envelope("x", b"1");
first.schema_id = Some("shared".into());
let mut second = snapshot_envelope("y", b"2");
second.r#type = "other".into();
second.schema_id = Some("shared".into());
let error = store
.compare_exchange_snapshot(&store.pull_all()?, &[first, second])
.unwrap_err();
assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::Rejected), "{error:#}");
assert!(error.to_string().contains("refused a snapshot it cannot store"), "{error}");
assert!(io_error_in(&error).is_none(), "{error:#}");
assert_eq!(fs::read(&path)?, before);
Ok(())
}
}