2026-05-08 01:13:15 +01:00
using System ;
2026-05-08 02:31:06 +01:00
using System.Buffers ;
2026-09-13 21:12:53 +02:00
using System.Collections.Concurrent ;
using System.Linq ;
using System.Reflection ;
2026-05-08 01:13:15 +01:00
using GameCult.Caching ;
using MessagePack ;
using MessagePack.Formatters ;
using MessagePack.Resolvers ;
namespace GameCult.Caching.MessagePack ;
public sealed class CultDocumentResolver : IFormatterResolver
{
public static readonly CultDocumentResolver Instance = new ( ) ;
private CultDocumentResolver ( ) { }
public IMessagePackFormatter < T > ? GetFormatter < T > ( )
{
var type = typeof ( T ) ;
if ( type . IsGenericType & & type . GetGenericTypeDefinition ( ) = = typeof ( CultRecordRef < > ) )
{
2026-05-08 01:50:15 +01:00
var formatterType = typeof ( global :: GameCult . Caching . CultRecordRefFormatter < > ) . MakeGenericType ( type . GetGenericArguments ( ) [ 0 ] ) ;
2026-05-08 01:13:15 +01:00
return ( IMessagePackFormatter < T > ) Activator . CreateInstance ( formatterType ) ! ;
}
return null ;
}
}
public static class CultDocumentMessagePackSerialization
{
2026-05-08 02:31:06 +01:00
private const int PersistedRecordFieldCount = 4 ;
2026-05-09 11:45:46 +01:00
private const int SchemaCatalogEntryFieldCount = 7 ;
private const int SchemaCatalogMemberFieldCount = 8 ;
2026-05-08 02:31:06 +01:00
private const int StoreSnapshotFieldCount = 3 ;
2026-09-13 21:12:53 +02:00
public static readonly MessagePackSerializerOptions Options = Compose ( Array . Empty < IFormatterResolver > ( ) ) ;
private static readonly ConcurrentDictionary < Assembly , MessagePackSerializerOptions > AssemblyOptions = new ( ) ;
public static MessagePackSerializerOptions OptionsFor ( Assembly documentAssembly )
{
if ( documentAssembly = = null ) throw new ArgumentNullException ( nameof ( documentAssembly ) ) ;
return AssemblyOptions . GetOrAdd ( documentAssembly , static assembly = >
{
var resolvers = assembly . GetCustomAttributes < CultCacheFormatterResolverAttribute > ( )
. Select ( attribute = > CreateResolver ( attribute . ResolverType ) )
. ToArray ( ) ;
return resolvers . Length = = 0 ? Options : Compose ( resolvers ) ;
} ) ;
}
private static MessagePackSerializerOptions Compose ( IFormatterResolver [ ] consumerResolvers )
{
return MessagePackSerializerOptions . Standard
2026-05-08 01:13:15 +01:00
. WithResolver ( CompositeResolver . Create (
2026-09-13 21:12:53 +02:00
consumerResolvers . Append ( CultDocumentResolver . Instance ) . Append ( StandardResolver . Instance ) . ToArray ( ) ) )
. WithSecurity ( CultMessagePackSecurity . Instance ) ;
}
private static IFormatterResolver CreateResolver ( Type resolverType )
{
var instance = resolverType . GetField ( "Instance" , BindingFlags . Public | BindingFlags . Static ) ? . GetValue ( null )
? ? resolverType . GetProperty ( "Instance" , BindingFlags . Public | BindingFlags . Static ) ? . GetValue ( null )
? ? ( resolverType . GetConstructor ( Type . EmptyTypes ) ! = null ? Activator . CreateInstance ( resolverType ) : null ) ;
return instance as IFormatterResolver
? ? throw new InvalidOperationException (
$"Formatter resolver {resolverType.FullName} needs a public static Instance or a public parameterless constructor and must implement {nameof(IFormatterResolver)}." ) ;
}
2026-05-08 01:13:15 +01:00
public static byte [ ] SerializeUntyped ( object value , Type type )
{
2026-07-20 01:48:56 +01:00
return SerializeUntyped ( value , type , CultDocumentRegistry . Shared ) ;
}
public static byte [ ] SerializeUntyped ( object value , Type type , CultDocumentRegistry registry )
{
if ( registry = = null ) throw new ArgumentNullException ( nameof ( registry ) ) ;
2026-05-08 02:19:27 +01:00
if ( value ! = null )
{
2026-07-20 01:48:56 +01:00
var descriptor = registry . GetRequired ( type ) ;
2026-05-08 02:19:27 +01:00
if ( descriptor . GeneratedPayloadSerializer ! = null )
{
return descriptor . GeneratedPayloadSerializer ( value ) ;
}
}
2026-09-13 21:12:53 +02:00
return MessagePackSerializer . Serialize ( type , value , OptionsFor ( type . Assembly ) ) ;
2026-05-08 01:13:15 +01:00
}
public static object DeserializeUntyped ( Type type , byte [ ] payload )
{
2026-07-20 01:48:56 +01:00
return DeserializeUntyped ( type , payload , CultDocumentRegistry . Shared ) ;
}
public static object DeserializeUntyped ( Type type , byte [ ] payload , CultDocumentRegistry registry )
{
if ( registry = = null ) throw new ArgumentNullException ( nameof ( registry ) ) ;
var descriptor = registry . GetRequired ( type ) ;
2026-05-08 02:19:27 +01:00
if ( descriptor . GeneratedPayloadDeserializer ! = null )
{
return descriptor . GeneratedPayloadDeserializer ( payload ) ;
}
2026-09-13 21:12:53 +02:00
return MessagePackSerializer . Deserialize ( type , payload , OptionsFor ( type . Assembly ) )
2026-05-09 11:14:23 +01:00
? ? throw new InvalidOperationException ( $"MessagePack returned null for Cult document type {type.FullName}." ) ;
2026-05-08 01:13:15 +01:00
}
2026-05-08 02:31:06 +01:00
public static byte [ ] SerializePersistedRecord ( CultPersistedRecord record )
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
WritePersistedRecord ( ref writer , record ) ;
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
public static CultPersistedRecord DeserializePersistedRecord ( byte [ ] payload )
{
var reader = new MessagePackReader ( payload ) ;
return ReadPersistedRecord ( ref reader ) ;
}
public static byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
writer . WriteArrayHeader ( StoreSnapshotFieldCount ) ;
writer . Write ( snapshot . FormatVersion ) ;
writer . WriteArrayHeader ( snapshot . SchemaCatalog . Length ) ;
foreach ( var entry in snapshot . SchemaCatalog )
{
WriteSchemaCatalogEntry ( ref writer , entry ) ;
}
writer . WriteArrayHeader ( snapshot . Records . Length ) ;
foreach ( var record in snapshot . Records )
{
WritePersistedRecord ( ref writer , record ) ;
}
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
public static CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] payload )
{
var reader = new MessagePackReader ( payload ) ;
var fieldCount = reader . ReadArrayHeader ( ) ;
var snapshot = new CultPersistedStoreSnapshot ( ) ;
if ( fieldCount > 0 )
{
snapshot . FormatVersion = reader . ReadString ( ) ? ? "cultcache.store.v1" ;
}
if ( fieldCount > 1 )
{
var catalogCount = reader . ReadArrayHeader ( ) ;
snapshot . SchemaCatalog = new CultSchemaCatalogEntry [ catalogCount ] ;
for ( var index = 0 ; index < catalogCount ; index + + )
{
snapshot . SchemaCatalog [ index ] = ReadSchemaCatalogEntry ( ref reader ) ;
}
}
if ( fieldCount > 2 )
{
var recordCount = reader . ReadArrayHeader ( ) ;
snapshot . Records = new CultPersistedRecord [ recordCount ] ;
for ( var index = 0 ; index < recordCount ; index + + )
{
snapshot . Records [ index ] = ReadPersistedRecord ( ref reader ) ;
}
}
for ( var index = StoreSnapshotFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return snapshot ;
}
private static void WritePersistedRecord ( ref MessagePackWriter writer , CultPersistedRecord record )
{
writer . WriteArrayHeader ( PersistedRecordFieldCount ) ;
writer . Write ( record . Key ) ;
writer . Write ( record . SchemaId ) ;
writer . Write ( record . StoredAt ) ;
writer . Write ( record . Payload ) ;
}
private static CultPersistedRecord ReadPersistedRecord ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var record = new CultPersistedRecord ( ) ;
if ( fieldCount > 0 )
{
record . Key = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 1 )
{
record . SchemaId = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
record . StoredAt = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
record . Payload = reader . ReadBytes ( ) ? . ToArray ( ) ? ? Array . Empty < byte > ( ) ;
}
for ( var index = PersistedRecordFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return record ;
}
private static void WriteSchemaCatalogEntry ( ref MessagePackWriter writer , CultSchemaCatalogEntry entry )
{
writer . WriteArrayHeader ( SchemaCatalogEntryFieldCount ) ;
writer . Write ( entry . SchemaId ) ;
writer . Write ( entry . SchemaName ) ;
writer . Write ( entry . SchemaVersion ) ;
writer . Write ( entry . ContentHash ) ;
writer . Write ( entry . CanonicalSchemaJson ) ;
writer . WriteArrayHeader ( entry . CompatibleSchemaIds . Length ) ;
foreach ( var schemaId in entry . CompatibleSchemaIds )
{
writer . Write ( schemaId ) ;
}
2026-05-09 11:45:46 +01:00
writer . WriteArrayHeader ( entry . Members . Length ) ;
foreach ( var member in entry . Members )
{
WriteSchemaCatalogMember ( ref writer , member ) ;
}
2026-05-08 02:31:06 +01:00
}
private static CultSchemaCatalogEntry ReadSchemaCatalogEntry ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var entry = new CultSchemaCatalogEntry ( ) ;
if ( fieldCount > 0 )
{
entry . SchemaId = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 1 )
{
entry . SchemaName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
entry . SchemaVersion = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
entry . ContentHash = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 4 )
{
entry . CanonicalSchemaJson = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 5 )
{
var compatibleCount = reader . ReadArrayHeader ( ) ;
entry . CompatibleSchemaIds = new string [ compatibleCount ] ;
for ( var index = 0 ; index < compatibleCount ; index + + )
{
entry . CompatibleSchemaIds [ index ] = reader . ReadString ( ) ? ? string . Empty ;
}
}
2026-05-09 11:45:46 +01:00
if ( fieldCount > 6 )
{
var memberCount = reader . ReadArrayHeader ( ) ;
entry . Members = new CultSchemaMemberCatalogEntry [ memberCount ] ;
for ( var index = 0 ; index < memberCount ; index + + )
{
entry . Members [ index ] = ReadSchemaCatalogMember ( ref reader ) ;
}
}
2026-05-08 02:31:06 +01:00
for ( var index = SchemaCatalogEntryFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return entry ;
}
2026-05-09 11:45:46 +01:00
private static void WriteSchemaCatalogMember ( ref MessagePackWriter writer , CultSchemaMemberCatalogEntry member )
{
writer . WriteArrayHeader ( SchemaCatalogMemberFieldCount ) ;
writer . Write ( member . Slot ) ;
writer . Write ( member . MemberName ) ;
writer . Write ( member . TypeName ) ;
writer . Write ( member . IsReference ) ;
writer . Write ( member . IsMany ) ;
writer . Write ( member . TargetSchemaName ) ;
writer . Write ( member . IsName ) ;
writer . Write ( member . IndexAlias ) ;
}
private static CultSchemaMemberCatalogEntry ReadSchemaCatalogMember ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var member = new CultSchemaMemberCatalogEntry ( ) ;
if ( fieldCount > 0 )
{
member . Slot = reader . ReadInt32 ( ) ;
}
if ( fieldCount > 1 )
{
member . MemberName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
member . TypeName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
member . IsReference = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 4 )
{
member . IsMany = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 5 )
{
member . TargetSchemaName = reader . ReadString ( ) ;
}
if ( fieldCount > 6 )
{
member . IsName = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 7 )
{
member . IndexAlias = reader . ReadString ( ) ;
}
for ( var index = SchemaCatalogMemberFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return member ;
}
2026-05-08 01:13:15 +01:00
}
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
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
public SingleFileMessagePackBackingStore ( string filePath , bool readOnly = false ) : base ( filePath , readOnly )
2026-05-08 01:13:15 +01:00
{
}
protected override byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
2026-05-08 02:31:06 +01:00
return CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ;
2026-05-08 01:13:15 +01:00
}
protected override CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] data )
{
2026-05-08 02:31:06 +01:00
return CultDocumentMessagePackSerialization . DeserializeSnapshot ( data ) ;
2026-05-08 01:13:15 +01:00
}
protected override byte [ ] SerializePayload ( object document )
{
2026-07-20 01:48:56 +01:00
return CultDocumentMessagePackSerialization . SerializeUntyped ( document , document . GetType ( ) , Registry ) ;
2026-05-08 01:13:15 +01:00
}
protected override object DeserializePayload ( Type documentType , byte [ ] payload )
{
2026-07-20 01:48:56 +01:00
return CultDocumentMessagePackSerialization . DeserializeUntyped ( documentType , payload , Registry ) ;
2026-05-08 01:13:15 +01:00
}
}