2026-05-30 00:39:39 +01:00
# CultCache Rust
`cultcache-rs` is the Rust port of the useful part of GameCult's CultCache:
a polymorphism-aware in-memory cache that remembers through pluggable backing
stores.
Consumer code should think in domain types:
```rust
let player = cache.get_required::<PlayerData>("player:meta")?;
cache.put("player:meta", &player)?;
```
The persistence layer should deal with envelopes, routing, MessagePack payload
bytes, and schema identity. Application code should not paw at loose JSON files
like a sad little bureaucrat with a clipboard.
## What It Is
CultCache is a domain cache with persistence adapters.
- `CultCache` is the query and mutation surface.
- Domain structs implement `DatabaseEntry` .
- Entries are stored behind a `type::key` identity, so multiple entry types
can share a logical key without colliding.
- Each payload is encoded directly from the known `DatabaseEntry` type into
MessagePack bytes.
- Backing stores are adapters, not the public data model.
- Writes persist to the resolved backing store before the in-memory cache is
updated.
- Type-specific backing stores beat generic backing stores.
- `SingleFileMessagePackBackingStore` is the first concrete store and guards
file access with a sidecar lock file.
This is not an ORM, not a database, and not distributed consensus in a novelty
hat. If multiple processes write the same backing file, use an external lock or
a coordinator.
## Current API
```rust
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings")]
struct Settings {
#[cultcache(key = 0)]
theme: String,
#[cultcache(key = 1, default)]
retries: u32,
}
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"));
2026-05-30 00:39:39 +01:00
cache.pull_all_backing_stores()?;
cache.put("app", &Settings {
theme: "ash".to_string(),
retries: 3,
})?;
let settings = cache.get_required::<Settings>("app")?;
# Ok::<(), anyhow::Error>(())
```
For a closed set of entries, generate a registry instead of repeating yourself:
```rust
use cultcache_rs::cultcache_registry;
cultcache_registry!(GameCultEntries {
Settings,
PlayerData,
});
let mut cache = CultCache::new();
cache.register_registry(GameCultEntries)?;
```
The important part is that callers retrieve domain values directly:
```rust
let settings: Settings = cache.get_required("app")?;
let all_settings: Vec<Settings> = cache.get_all()?;
```
The cache is the ergonomic surface. The backing store is the memory prosthetic.
## How This Maps From Original CultCache
The original C# CultCache relies on runtime reflection:
- cacheable models inherit from `DatabaseEntry`
- the cache scans child classes at startup
- `Get<T>(id)` returns a `T`
- optional `INamedEntry` enables name lookup
- optional field/property indexes are registered by member name
- backing stores pull/push/delete `DatabaseEntry` values
Rust does not have C#-style assembly scanning or runtime subclass discovery.
The closest honest Rust translation is:
- domain structs implement a marker trait, `DatabaseEntry`
- the derive macro generates a positional array formatter from explicit integer
field slots
- the envelope carries the polymorphic type discriminator
- unknown persisted type ids fail closed instead of constructing arbitrary
runtime types
- a generated registry should eventually replace repetitive manual
registration
Manual `register_entry_type::<T>()` is still accepted, but entry identity no
longer needs a hand-written impl: `#[derive(DatabaseEntry)]` declares it beside
the domain type. `cultcache_registry!` provides the current generated resolver
surface for a closed entry set.
## Intended Rust Ergonomics
The target user-facing shape should be:
```rust
#[derive(Clone, DatabaseEntry)]
#[cultcache(type = "player")]
pub struct PlayerData {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub name: String,
#[cultcache(key = 2)]
pub faction: String,
}
let mut cache = CultCache::builder()
.with_generated_entries(game_entries())
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
.with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc"))
2026-05-30 00:39:39 +01:00
.build()?;
cache.pull_all_backing_stores()?;
let player = cache.get_required::<PlayerData>("player:ari")?;
```
The current `cultcache_registry!` macro is deliberately simple. The remaining
ergonomic endpoint is automatic inventory or build-script generation so users
do not have to maintain the registry list by hand.
The registry is the Rust equivalent of C# reflection and MessagePack resolver
setup. It should know:
- entry type id
- generated MessagePack/serde formatter path
- Rust type name / schema name
- optional store domain route
- optional name key extractor
- optional secondary indexes
- optional global singleton marker
In other words: code generation should restore the original CultCache feeling
without pretending Rust has runtime reflection hiding under the floorboards.
## Current Surface
- `CultCache::new`
- `register_entry_type::<T>`
- `add_generic_backing_store`
- `add_backing_store`
- `pull_all_backing_stores`
- `get::<T>`
- `get_required::<T>`
- `get_envelope::<T>`
- `get_required_envelope::<T>`
- `get_all::<T>`
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
- `get_all_with_keys::<T>`
- `soa::<T>`
2026-05-30 00:39:39 +01:00
- `put::<T>`
- `put_envelope::<T>`
- `update::<T>`
- `delete::<T>`
- `snapshot`
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
- `snapshot_soa`
- `load_soa`
2026-05-30 00:39:39 +01:00
- `SingleFileMessagePackBackingStore`
2026-10-11 03:50:30 +02:00
## Unions
A union that crosses runtimes is written `[armKey, [armSlots...]]` , the shape the C# reference writes for
`[Union(key, type)]` . Derive `CultUnion` on an enum: every variant carries `#[cultcache(key = N)]` and is a unit
(`[N, []]` ) or a newtype over a `#[derive(CultSlots)]` struct, which is a `DatabaseEntry` 's slot array without the
cultcache-rs-derive: arm keys are Int32 and given once, an arm is a slots record, refusals classified like the reference
Key attributes (variant, field, entry type/schema) are refused when repeated. A CultUnion key
parses as the reference's Int32, negatives included; a key outside it is a compile error. Decode
classifies the key as the reference does: non-integer, outside Int32, undeclared (named), and a
pair of the wrong length is refused before its key is read; no value is echoed beyond the
undeclared key the reference names. The SlotRecord trait, implemented by the shared slot
generator, bounds an arm payload, so a String or Option arm is a compile error. compile_fail
fixtures use keys other than 0; the derive crate's unit tests assert each refusal's message.
Unverified: committed before the first build; the verification job runs next.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:37:47 +02:00
entry consts (a `String` or an `Option` is not an arm, and is a compile error). An arm key is the reference's Int32, negatives included, and is given once. A key the enum does not declare is a decode error naming the enum and the declared keys, and never
2026-10-11 03:50:30 +02:00
quoting the payload. A null union is `Option<Enum>` . Plain serde enums are untouched and keep their bytes.
```rust
use cultcache_rs::{CultSlots, CultUnion};
#[derive(Clone, Debug, PartialEq, CultUnion)]
enum Node {
#[cultcache(key = 0)]
Box(BoxArm),
#[cultcache(key = 1)]
Text(TextArm),
#[cultcache(key = 5)]
Spacer, // a unit arm: [5, []]
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct BoxArm {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
children: Vec<Node>, // recursive
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct TextArm {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
content: String,
}
let tree = Node::Box(BoxArm { id: "root".into(), children: vec![Node::Spacer] });
let bytes = rmp_serde::to_vec(&tree)?;
assert_eq!(rmp_serde::from_slice::<Node>(&bytes)?, tree);
# Ok::<(), Box<dyn std::error::Error>>(())
```
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
## Structure Of Arrays
The C# CultCache hot path exposes CPU-local SoA scans as:
```csharp
Span<float> x = cache.Soa<PlayerTransform>()
.Column<float>(nameof(PlayerTransform.PositionX))
.Span;
```
Rust cannot discover those columns through runtime reflection, so each domain
type declares its column extractors explicitly with `SoaDocument` or the
`cultcache_soa!` helper:
```rust
cultcache_soa!(PlayerTransform {
"position_x" => |row: &PlayerTransform| row.position_x,
"health" => |row: &PlayerTransform| row.health,
});
let table = cache.soa::<PlayerTransform>()?;
let health = table.column::<i32>("health")?;
```
This is the same public shape as the C# rite: callers ask the cache for a typed
table, then pull contiguous typed columns by name. The Rust version is explicit
about the extractor table because the closed-world schema boundary is a virtue,
not a missing reflection party trick.
2026-05-30 00:39:39 +01:00
## Backing Store Routing
Generic store:
```rust
Give every cultcache-rs entry type one home store
Cut 5 (Rust). The four mirror loops in put, put_envelope, put_raw_envelope and
delete are deleted, and so is put_prepared_batch's route.len() != 1 branch.
resolve_route_index returns the single home store (the store that claims the
type, else the generic store) and replaces resolve_route_indices.
add_backing_store and add_generic_backing_store return Result and refuse a
second generic store or a type already claimed by another store, attaching
nothing when they refuse. Callers take the Result: the crate's tests, the
cultcache_interop example, cultnet-rs's tests and interop peer example, and
CultMeshNode::create_node in cultmesh-rs.
Tests: second_generic_store_is_rejected, type_claimed_twice_is_rejected;
type_specific_store_routes_before_generic_store stays, now attaching the routed
store before the generic one. No bytes change.
The store-composition contract's runtime table now records Rust, TypeScript
and Python as conforming to one home store per type, and the README routing
section drops its non-conformance note.
Version 0.1.0 -> 0.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:38:41 +02:00
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"))?;
2026-05-30 00:39:39 +01:00
```
Type-specific store:
```rust
cache.add_backing_store(
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
SingleFileMessagePackBackingStore::new("players.cc"),
2026-05-30 00:39:39 +01:00
["player"],
Give every cultcache-rs entry type one home store
Cut 5 (Rust). The four mirror loops in put, put_envelope, put_raw_envelope and
delete are deleted, and so is put_prepared_batch's route.len() != 1 branch.
resolve_route_index returns the single home store (the store that claims the
type, else the generic store) and replaces resolve_route_indices.
add_backing_store and add_generic_backing_store return Result and refuse a
second generic store or a type already claimed by another store, attaching
nothing when they refuse. Callers take the Result: the crate's tests, the
cultcache_interop example, cultnet-rs's tests and interop peer example, and
CultMeshNode::create_node in cultmesh-rs.
Tests: second_generic_store_is_rejected, type_claimed_twice_is_rejected;
type_specific_store_routes_before_generic_store stays, now attaching the routed
store before the generic one. No bytes change.
The store-composition contract's runtime table now records Rust, TypeScript
and Python as conforming to one home store per type, and the README routing
section drops its non-conformance note.
Version 0.1.0 -> 0.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:38:41 +02:00
)?;
2026-05-30 00:39:39 +01:00
```
2026-09-13 16:01:32 +02:00
The contract, shared with every runtime, is one home store per document type
(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
2026-05-30 00:39:39 +01:00
2026-09-13 16:01:32 +02:00
- a type-specific store owns its types; otherwise the generic store is the home
Give every cultcache-rs entry type one home store
Cut 5 (Rust). The four mirror loops in put, put_envelope, put_raw_envelope and
delete are deleted, and so is put_prepared_batch's route.len() != 1 branch.
resolve_route_index returns the single home store (the store that claims the
type, else the generic store) and replaces resolve_route_indices.
add_backing_store and add_generic_backing_store return Result and refuse a
second generic store or a type already claimed by another store, attaching
nothing when they refuse. Callers take the Result: the crate's tests, the
cultcache_interop example, cultnet-rs's tests and interop peer example, and
CultMeshNode::create_node in cultmesh-rs.
Tests: second_generic_store_is_rejected, type_claimed_twice_is_rejected;
type_specific_store_routes_before_generic_store stays, now attaching the routed
store before the generic one. No bytes change.
The store-composition contract's runtime table now records Rust, TypeScript
and Python as conforming to one home store per type, and the README routing
section drops its non-conformance note.
Version 0.1.0 -> 0.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:38:41 +02:00
- a second generic store is an error, and so is a type claimed by a second
store; both registrations return `Err` and attach nothing
2026-09-13 16:01:32 +02:00
- a write touches exactly one store; there are no mirrors
Give every cultcache-rs entry type one home store
Cut 5 (Rust). The four mirror loops in put, put_envelope, put_raw_envelope and
delete are deleted, and so is put_prepared_batch's route.len() != 1 branch.
resolve_route_index returns the single home store (the store that claims the
type, else the generic store) and replaces resolve_route_indices.
add_backing_store and add_generic_backing_store return Result and refuse a
second generic store or a type already claimed by another store, attaching
nothing when they refuse. Callers take the Result: the crate's tests, the
cultcache_interop example, cultnet-rs's tests and interop peer example, and
CultMeshNode::create_node in cultmesh-rs.
Tests: second_generic_store_is_rejected, type_claimed_twice_is_rejected;
type_specific_store_routes_before_generic_store stays, now attaching the routed
store before the generic one. No bytes change.
The store-composition contract's runtime table now records Rust, TypeScript
and Python as conforming to one home store per type, and the README routing
section drops its non-conformance note.
Version 0.1.0 -> 0.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:38:41 +02:00
- a write whose type has no home store is an error
2026-05-30 00:39:39 +01:00
## Persistence Semantics
`put` persists before mutating the in-memory cache. If persistence fails, the
cache does not pretend the write succeeded.
The envelope is MessagePack, and the payload inside each envelope is also raw
MessagePack bytes encoded from the registered concrete `DatabaseEntry` type.
That avoids the old bootstrap path where payloads were normalized through
`serde_json::Value` .
For bit-compatible neighbors, that also enables a real fast lane:
- `get_envelope::<T>()` exports the canonical persisted bytes for a typed entry
- `put_envelope::<T>()` ingests the same envelope into another cache instance
without re-encoding the payload first
It still decodes once for validation and typed reads. We are not pretending one
`Vec<u8>` became metaphysically zero-copy because we believed in it harder. But
the stupid decode/re-encode loop is gone.
The `DatabaseEntry` derive does not trust Rust source field order as the durable
schema. Every persisted member must declare a stable integer slot:
```rust
#[derive(Clone, DatabaseEntry)]
#[cultcache(type = "player")]
pub struct PlayerData {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub name: String,
#[cultcache(key = 2, default)]
pub level: u32,
}
```
The derive emits a tuple/array formatter. Gaps serialize as nil. Missing fields
marked `default` use `Default::default()` when older payloads are read. Deleted
field slots should stay reserved until an explicit store migration rewrites the
data.
Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.
CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.
The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.
Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:44:32 +02:00
The single-file MessagePack store rewrites an atomic snapshot. That is a sane
starting point for small typed state surfaces, settings, Epiphany agent memory,
2026-05-30 00:39:39 +01:00
heartbeat state, and other compact control-plane data. Large corpora should use
a sharded store or a real database instead of asking one file to become a
warehouse and then acting wounded when physics invoices us.
The single-file store uses a sidecar lock file for shared reads and exclusive
writes. That protects ordinary multi-process access to the same file. It is
still not a multi-master replication protocol, and it is not a substitute for a
coordinator when higher-level write ordering matters.
2026-09-30 16:21:06 +02:00
A write that fails part way, for example on a full disk, removes its staging
file and leaves the store as it was. A read that fails tells you why through
the error chain:
```rust
use cultcache_rs::{CacheBackingStore, CultCacheStoreUnreadable, CultCacheStoreUnreadableKind, SingleFileMessagePackBackingStore};
let store = SingleFileMessagePackBackingStore::new("cache.cc");
if let Err(error) = store.pull_all() {
match error.downcast_ref::<CultCacheStoreUnreadable>().map(|unreadable| unreadable.kind) {
// The bytes were read and are not a store: truncated, garbled, or invalid.
Some(CultCacheStoreUnreadableKind::Undecodable) => {}
// The header names a format this runtime does not read, such as a newer one.
Some(CultCacheStoreUnreadableKind::UnsupportedFormat) => {}
// The bytes could not be read at all: a std::io::Error is in the chain.
_ => {}
}
}
```
An I/O failure never carries `CultCacheStoreUnreadable` , and the chain under
`CultCacheStoreUnreadable` never holds a `std::io::Error` .
2026-09-30 17:04:39 +02:00
Only a store file that does not exist (`std::io::ErrorKind::NotFound` ) reads as
an empty store. Any other failure to reach it, such as a symlink loop, a file
where a parent directory should be, or a missing permission, is an I/O error.
A store file that exists and holds zero bytes is `Undecodable` : no CultCache
2026-09-30 18:37:59 +02:00
writer leaves one. A symbolic link whose target is gone is not a missing store:
cultcache-rs: keyed and document refusals keep no stored value; encode refusals are Rejected
- The redb envelope decode (get by identity and pull_all) and every typed
document decode (get, get_all, get_all_with_keys, put_envelope, prepare_entry)
kept serde's text, which quotes stored values. They now keep CultCache's
context, which names the entry by type and key, and a fixed description of
the decoder failure's kind.
- A snapshot the store cannot hold (two types claiming one schema id) was
reported as NotReplaced, so a caller retrying on that kind looped forever.
It is CultCacheStoreWriteFailedKind::Rejected now, raised before any I/O.
- release_lock and the read-only snapshot state one unlock policy: an unlock
failure never replaces the result. Only the read-only path, whose API has an
observer, reports it. release_lock also names the case where closing does not
release the lock: a duplicate of the open file description, such as a child
forked without exec.
- README: reads refuse a dangling link, but writes do not follow links yet. A
write through a live link, and push_all through a dangling one, replace the
link. Two tests pin that behaviour, named for R3 to decide between resolving
and refusing. The write example classifies Rejected apart from NotReplaced.
- The shared-gate test waits 30 s and 60 s for the writer instead of 1 s, and
checks that the file on disk is untouched while the gate is held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:54:05 +02:00
reading it is an I/O error, so a read-then-write (`push` , `delete` , the exchanges)
fails before writing. Writes do not yet follow links: a write through a live link,
and a `push_all` through a dangling one, replace the link with a regular file and
leave its target alone. A refusal names no
2026-09-30 18:37:59 +02:00
value read from the file; it names a record only by its key and schema id, and
a header only when it has the shape `cultcache.store.v<digits>` .
A write that fails says what it left on disk:
```rust
use cultcache_rs::{CultCacheStoreWriteFailed, CultCacheStoreWriteFailedKind};
fn outcome(error: &anyhow::Error) {
match error.downcast_ref::<CultCacheStoreWriteFailed>().map(|failed| failed.kind) {
cultcache-rs: keyed and document refusals keep no stored value; encode refusals are Rejected
- The redb envelope decode (get by identity and pull_all) and every typed
document decode (get, get_all, get_all_with_keys, put_envelope, prepare_entry)
kept serde's text, which quotes stored values. They now keep CultCache's
context, which names the entry by type and key, and a fixed description of
the decoder failure's kind.
- A snapshot the store cannot hold (two types claiming one schema id) was
reported as NotReplaced, so a caller retrying on that kind looped forever.
It is CultCacheStoreWriteFailedKind::Rejected now, raised before any I/O.
- release_lock and the read-only snapshot state one unlock policy: an unlock
failure never replaces the result. Only the read-only path, whose API has an
observer, reports it. release_lock also names the case where closing does not
release the lock: a duplicate of the open file description, such as a child
forked without exec.
- README: reads refuse a dangling link, but writes do not follow links yet. A
write through a live link, and push_all through a dangling one, replace the
link. Two tests pin that behaviour, named for R3 to decide between resolving
and refusing. The write example classifies Rejected apart from NotReplaced.
- The shared-gate test waits 30 s and 60 s for the writer instead of 1 s, and
checks that the file on disk is untouched while the gate is held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:54:05 +02:00
// The entries cannot be stored; retrying them fails the same way.
Some(CultCacheStoreWriteFailedKind::Rejected) => {}
// Staging, fsync or rename failed: the store file holds what it held before,
// and a retry may succeed once the fault (a full disk, say) clears.
2026-09-30 18:37:59 +02:00
Some(CultCacheStoreWriteFailedKind::NotReplaced) => {}
// The rename landed and the directory sync failed: the new snapshot is in
// place but may not survive a crash.
Some(CultCacheStoreWriteFailedKind::ReplacedNotDurable) => {}
_ => {}
}
}
```
2026-09-30 17:04:39 +02:00
2026-05-30 00:39:39 +01:00
## Near-Term Ergonomic Improvements
1. **Derive macro **
- `#[derive(DatabaseEntry)]`
- `#[cultcache(type = "...")]`
- `#[cultcache(key = N)]` on every persisted field
- tuple/array formatter generation landed
- optional `#[cultcache(name)]` , `#[cultcache(index)]` , and
`#[cultcache(global)]` are still future work
2. **Generated registry **
- a `CultCacheRegistry` trait
- `cache.register_registry(GameCultEntries)`
- basic macro registry landed
- automatic inventory/build-script generation and store routes are future work
3. **Name and index lookups **
- `cache.get_by_name::<T>("Potion")`
- `cache.get_by_index::<T>("faction", "Lucent")`
- generated extractors instead of reflection strings
4. **Schema/version metadata **
- schema version in the envelope or payload metadata
- explicit migration hooks
- refusal on unknown persisted entry types unless a migration/resolver is
installed
5. **Projection helpers **
- JSON projection for review and git diffs
- vector stripping / large-field elision for agent memory surfaces
## Why Not Skip Registration Immediately?
Rust can only deserialize polymorphic data into concrete types if something
maps the persisted `type` discriminator to the Rust type. In C#, reflection and
MessagePack resolvers can discover a closed `DatabaseEntry` inheritance tree at
runtime and route it through known formatters. In Rust, that map has to come from
somewhere:
- explicit registration
- a generated registry
- a macro inventory crate
- a hand-written resolver
Explicit registration is the simplest honest implementation. Generated registry
is the ergonomic destination. Hand-written resolver tables are the punishment
we give ourselves if we get lazy.
## Security Model
CultCache should never parse arbitrary structured data into arbitrary runtime
types. It only accepts persisted envelopes whose `type` discriminator resolves
to a known `DatabaseEntry` type registered in the cache or generated registry.
That is the Rust version of the original trick:
- C# refuses to store objects outside the `DatabaseEntry` root type.
- Runtime-visible `DatabaseEntry` subclasses get dedicated formatters.
- Unknown or unregistered types are rejected.
- MessagePack payload bytes deserialize through known concrete formatters, not
an open-world type loader or generic structured-value bridge.
The derive/registry work should preserve that shape. Its job is to remove
manual registration ceremony, not to loosen the closed-world boundary.
2026-10-14 02:38:23 +02:00
Payload nesting is bounded at 256 MessagePack containers, checked before any
2026-10-14 19:48:58 +02:00
recursion; past the bound the refusal is a typed `PayloadTooDeep` . Typed decode
of stored bytes then grows its stack: the decoder checks the remaining stack
2026-10-14 21:19:13 +02:00
at entry and `serde_stacker` checks it at every container, moving
2026-10-14 19:48:58 +02:00
onto a fresh segment whenever less than 512 KiB remains, so a payload inside the
bound decodes on a small thread instead of aborting the process. Envelope and
store reads (single-file, redb, v1 snapshots) use the same grown decoder and
keep accepting and refusing what they did, without the payload walk.
The guarantee holds for records the derives produce. `MAX_RECORD_INLINE_BYTES`
(16 KiB) is the largest `size_of` a `DatabaseEntry` , `CultSlots` or `CultUnion`
record may have, and the derives refuse a larger one at compile time: put its
large data behind a `Vec` or a `Box` . A field of a type the derives did not
produce that is larger than that, behind a `Box` or `Vec` , is outside the
2026-10-14 20:57:58 +02:00
guarantee. The decoder's own frames have a fixed floor that no payload changes: at the bound a thread decodes a record of
2026-10-14 21:19:13 +02:00
this size from about 56 KiB in a release build and 72 KiB in a debug build 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). Stack growth was verified on
2026-10-14 21:19:19 +02:00
x86_64-pc-windows-msvc by hand (`decode_stack` , revision 10a39dac, 13 tests, debug and release); a `stacker` bump or a change
2026-10-14 21:19:13 +02:00
to the stack-size tests repeats it.
2026-10-14 20:57:58 +02:00
The crate does not
2026-10-14 19:48:58 +02:00
build for wasm32 (`fs2` ), and no wasm behaviour is claimed.
### Build dependencies
Stack growth adds these crates to every consumer's build, and a C toolchain
(`psm` , `stacker` ). On all targets: `serde_stacker` 0.1.14, `stacker` 0.1.25,
`psm` 0.1.32, `cc` 1.8.0, `find-msvc-tools` 0.1.14, `shlex` 2.0.1 (MIT OR
Apache-2.0), `object` 0.39.1 (Apache-2.0 OR MIT) and `ar_archive_writer` 0.5.3
(Apache-2.0 WITH LLVM-exception, build-only). On `x86_64-pc-windows-msvc` also
`libc` 0.2.190 and `windows-sys` 0.61.2 (MIT OR Apache-2.0), beside the 0.59 the
crate uses itself.
2026-10-14 02:38:23 +02:00
2026-05-30 00:39:39 +01:00
## License
Private GameCult infrastructure for now. Public packaging can wait until the
API grows enough teeth to deserve strangers.