7953 lines
No EOL
326 KiB
Rust
7953 lines
No EOL
326 KiB
Rust
//! 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(¤t)?;
|
||
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(¤t)?;
|
||
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(¤t)?;
|
||
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(¤t_file)?;
|
||
self.require_file_identity(¤t).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", ¬e_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, ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_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", ¬e_one())?;
|
||
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
|
||
store.push(&settings)?;
|
||
let before = std::fs::read(&store_path)?;
|
||
let error = store.push(¬e).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", ¬e_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", ¬e).is_err());
|
||
let (entry, parsed) = cache.prepare_entry_named("named", ¬e)?;
|
||
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(¤t)?;
|
||
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(¤t, 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(¤t, 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(¤t)?;
|
||
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(¤t),
|
||
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(¤t)?;
|
||
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(¤t)?;
|
||
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(¤t)?;
|
||
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(
|
||
¤t,
|
||
vec![test_envelope("a", "one", b"collision")],
|
||
)?);
|
||
assert!(
|
||
store
|
||
.append_if_snapshot_unchanged(¤t, 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(())
|
||
}
|
||
} |