CultLib/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs

379 lines
12 KiB
C#
Raw Normal View History

using System;
using System.Buffers;
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<>))
{
var formatterType = typeof(global::GameCult.Caching.CultRecordRefFormatter<>).MakeGenericType(type.GetGenericArguments()[0]);
return (IMessagePackFormatter<T>)Activator.CreateInstance(formatterType)!;
}
return null;
}
}
public static class CultDocumentMessagePackSerialization
{
private const int PersistedRecordFieldCount = 4;
private const int SchemaCatalogEntryFieldCount = 7;
private const int SchemaCatalogMemberFieldCount = 8;
private const int StoreSnapshotFieldCount = 3;
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)
{
return SerializeUntyped(value, type, CultDocumentRegistry.Shared);
}
public static byte[] SerializeUntyped(object value, Type type, CultDocumentRegistry registry)
{
if (registry == null) throw new ArgumentNullException(nameof(registry));
if (value != null)
{
var descriptor = registry.GetRequired(type);
if (descriptor.GeneratedPayloadSerializer != null)
{
return descriptor.GeneratedPayloadSerializer(value);
}
}
return MessagePackSerializer.Serialize(type, value, Options);
}
public static object DeserializeUntyped(Type type, byte[] payload)
{
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);
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}.");
}
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);
}
writer.WriteArrayHeader(entry.Members.Length);
foreach (var member in entry.Members)
{
WriteSchemaCatalogMember(ref writer, member);
}
}
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;
}
}
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);
}
}
for (var index = SchemaCatalogEntryFieldCount; index < fieldCount; index++)
{
reader.Skip();
}
return entry;
}
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;
}
}
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)
{
}
protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
{
return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot);
}
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
{
return CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
}
protected override byte[] SerializePayload(object document)
{
return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry);
}
protected override object DeserializePayload(Type documentType, byte[] payload)
{
return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
}
}