CultLib/src/GameCult.Caching
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Meta Vens a3cc394241 Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.

Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
  constructor, the initializeGlobals plumbing in CultCacheMessagePack,
  ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
  existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
  CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
  CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
  gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
  catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
  so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
  called it by reflection and now calls Remove(key)

Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
  already attached, one untyped store, no claimed home type, no admitted
  record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
  (home, read-only, loaded-from-home, global singleton), lands durably, then
  applies and publishes; a pull hands its whole delta over in one call, so a
  refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
  CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
  candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
  CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
  sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
  evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
  store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
  it replaces

Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
..
Contracts Apply Soul fixes to the store composition docs and routing tests 2026-09-13 16:13:20 +02:00
CultCache.cs Route each document type to one home store; attach hydrates; commit explicit batches 2026-09-13 17:57:58 +02:00
CultDocumentContracts.cs Strip doc comments from the Caching projects 2026-09-13 16:54:43 +02:00
CultGeneratedDocumentMetadata.cs Strip doc comments from the Caching projects 2026-09-13 16:54:43 +02:00
CultManagedDocument.cs Strip doc comments from the Caching projects 2026-09-13 16:54:43 +02:00
GameCult.Caching.csproj Drop the unused GameCult.Logging reference from GameCult.Caching 2026-09-13 17:17:14 +02:00
README.md Make SoA cache-managed document storage 2026-06-13 01:44:05 +01:00
ReflectionExtensions.cs Rewrite CultCache as attribute-first storage 2026-05-08 01:13:15 +01:00

GameCult.Caching

GameCult.Caching is an attribute-driven typed cache with a canonical single-file MessagePack store. Domain models stay clean. The cache owns record keys, schema ids, timestamps, and persistence metadata instead of smearing that sludge across every class.

Authoring Model

Cacheable models are plain classes annotated with cache intent.

using GameCult.Caching;
using MessagePack;

[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
public sealed class ItemData
{
    [Key(0)]
    [CultName]
    public string Name = string.Empty;

    [Key(1)]
    [CultIndex]
    public string Category = string.Empty;

    [Key(2)]
    public int Value;
}

Available cache attributes:

  • CultDocument(schemaName, schemaVersion)
  • CultName
  • CultIndex(alias?)
  • CultGlobal
  • CultReference(targetType?, many: false)

If you want compile-time document metadata instead of runtime assembly spelunking, add the GameCult.Caching.MessagePack.Generator analyzer to the project that declares your [CultDocument] types. It emits a metadata provider the cache registry can load directly, while reflection stays behind as fallback for projects that have not taken the hint yet.

Main Concepts

Record keys and handles

CultCache gives each stored document a cache-owned CultRecordKey. You work with typed handles instead of forcing storage identity into the domain object.

var cache = new CultCache();
var handle = await cache.AddAsync(new ItemData
{
    Name = "Potion",
    Category = "Consumable",
    Value = 50
});

var potion = cache.Get<ItemData>(handle.Key);

Name and index lookups

Name and index lookups are driven from attributes, not manual registration.

var byName = cache.GetByName<ItemData>("Potion");
var byIndex = cache.GetByIndex<ItemData>("Category", "Consumable");

Explicit references

References stay explicit in the domain. The cache will resolve them on demand, but it will not quietly build an entire haunted object graph behind your back.

[CultDocument("gamecult.quest_data", "gamecult.quest_data.v1")]
public sealed class QuestData
{
    [Key(0)] public string Title = string.Empty;
    [Key(1)] [CultReference] public CultRecordRef<ItemData> Reward;
}

Persistence Shape

CultCache persistence is store-shaped, not entry-shaped:

  1. store header
  2. embedded schema catalog
  3. records

Each persisted record carries:

  • key
  • schemaId
  • storedAt
  • payload

The domain payload stays free of storage metadata.

Backing Store

CultCache now has one sanctioned persistence format:

  • SingleFileMessagePackBackingStore

It persists the whole store as:

  1. store format version
  2. embedded schema catalog
  3. persisted records

Each persisted record carries:

  • key
  • schemaId
  • storedAt
  • payload

Happy Path

using GameCult.Caching;
using GameCult.Caching.MessagePack;

var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");

var handle = await cache.UpsertAsync(new ItemData
{
    Name = "Potion",
    Category = "Consumable",
    Value = 50
});

await cache.FlushAsync();

If you want the explicit assembly rite, the lower-level CultCache + SingleFileMessagePackBackingStore path is still there. The helper is just the blessed front door.

Managed Documents And SoA Storage

Gameplay code keeps authoring normal typed documents. CultCache owns the record identity, reactive presentation, and CPU-local structure-of-arrays table behind that POCO surface.

[CultDocument("game.player_transform", "game.player_transform.v1")]
public sealed class PlayerTransform
{
    [CultName] public string Name = string.Empty;
    public float PositionX;
    public float PositionY;
    public int Health;
}

var player = cache.Document<PlayerTransform>(new CultRecordKey("player:alice"));

await player.ReplaceAsync(new PlayerTransform
{
    Name = "Alice",
    PositionX = 4,
    PositionY = 8,
    Health = 100
});

using var subscription = player.Watch().Subscribe(current => Render(current));

player.Value!.Health = 90;
await player.CommitAsync();

var table = cache.Soa<PlayerTransform>();

Span<float> x = table.Column<float>(nameof(PlayerTransform.PositionX)).Span;
Span<float> y = table.Column<float>(nameof(PlayerTransform.PositionY)).Span;
Span<int> health = table.Column<int>(nameof(PlayerTransform.Health)).Span;

for (var row = 0; row < table.Count; row++)
{
    x[row] += 0.016f;
}

Document<T>() is the standard presentation. Soa<T>() exposes the cache-owned physical table for hot loops and diagnostics; it is not a second domain model callers have to maintain.

Persistence discipline

Persistence stays manual by default. Mutations mark the cache and attached backing stores dirty; nothing silently flushes every object write just because that felt convenient in the moment.

Useful surfaces:

  • CultCacheMessagePack.OpenAsync(path)
  • cache.IsDirty
  • store.IsDirty
  • cache.Document<T>(key)
  • cache.Soa<T>()
  • cache.UpsertAsync(document)
  • cache.FlushAllBackingStores()
  • cache.FlushAsync()
  • cache.FlushBackingStore(store)
  • cache.PrepareForReloadOrShutdown()
  • cache.PrepareForReloadOrShutdownAsync()
  • cache.FlushAttachedStoresOnDispose
  • store.FlushOnDispose

This is built for callers like Aquarium that want durable runtime state while still choosing when to pay the write cost.

Single-file MessagePack remains a single-writer format. The atomic replace path protects against partial writes, not competing writers trampling each other.

Schema migration diagnostics

CultCache does not quietly fall back from exact schemaId resolution to "close enough, probably." When a persisted schema drifts, the cache now emits a typed migration report describing:

  • exact vs compatible-drift resolution
  • ignored extra slots
  • defaulted missing slots
  • warning codes/messages

Compatibility rules and canonical fixture receipts live in Contracts/cultcache-schema-compatibility.md.