2026-05-08 01:13:15 +01:00
|
|
|
using System;
|
2026-05-08 02:31:06 +01:00
|
|
|
using System.Buffers;
|
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-05-08 01:13:15 +01:00
|
|
|
public static readonly MessagePackSerializerOptions Options =
|
|
|
|
|
MessagePackSerializerOptions.Standard
|
|
|
|
|
.WithResolver(CompositeResolver.Create(
|
|
|
|
|
CultDocumentResolver.Instance,
|
|
|
|
|
StandardResolver.Instance))
|
|
|
|
|
.WithSecurity(MessagePackSecurity.UntrustedData);
|
|
|
|
|
|
|
|
|
|
public static byte[] Serialize<T>(T value)
|
|
|
|
|
{
|
|
|
|
|
return MessagePackSerializer.Serialize(value, Options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static T Deserialize<T>(byte[] payload)
|
|
|
|
|
{
|
|
|
|
|
return MessagePackSerializer.Deserialize<T>(payload, Options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-05-08 01:13:15 +01:00
|
|
|
return MessagePackSerializer.Serialize(type, value, Options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-05-09 11:14:23 +01:00
|
|
|
return MessagePackSerializer.Deserialize(type, payload, Options)
|
|
|
|
|
?? 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
|
|
|
}
|
|
|
|
|
}
|