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

386 lines
13 KiB
C#
Raw Normal View History

using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Linq;
using System.Reflection;
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 = 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
.WithResolver(CompositeResolver.Create(
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)}.");
}
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));
Delete the CultCache MessagePack source generator The reflective CultDocumentRegistry is now the only descriptor authority, and payloads always go through MessagePackSerializer with OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed, Unity always ran the reflective path, and no benchmark showed a gain, while the generator duplicated the registry's shape rules. Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution, project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata (provider attribute, interface, definitions, loader), the generated descriptor and codec branches in CultCache and CultDocumentMessagePackSerialization, DocumentShapeSweepTests and the generator half of the unkeyed test, the Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo. The registry rejects, with a message naming the fix, what MessagePackSerializer cannot serialize and what the generator used to paper over: a document without [MessagePackObject], a non-public document without AllowPrivate = true, and a non-public member of an AllowPrivate document that is not [IgnoreMember]. DocumentShapeTests pins every rejection message and the accepted shapes' round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin). Rejected shapes are emitted into a dynamic assembly, which registry discovery skips. Nested member type names use reflection's Outer+Inner form, not the generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned (bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj excluded) found no member type name naming a nested type in either form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
if (value != null) registry.GetRequired(type);
return MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly));
}
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));
Delete the CultCache MessagePack source generator The reflective CultDocumentRegistry is now the only descriptor authority, and payloads always go through MessagePackSerializer with OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed, Unity always ran the reflective path, and no benchmark showed a gain, while the generator duplicated the registry's shape rules. Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution, project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata (provider attribute, interface, definitions, loader), the generated descriptor and codec branches in CultCache and CultDocumentMessagePackSerialization, DocumentShapeSweepTests and the generator half of the unkeyed test, the Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo. The registry rejects, with a message naming the fix, what MessagePackSerializer cannot serialize and what the generator used to paper over: a document without [MessagePackObject], a non-public document without AllowPrivate = true, and a non-public member of an AllowPrivate document that is not [IgnoreMember]. DocumentShapeTests pins every rejection message and the accepted shapes' round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin). Rejected shapes are emitted into a dynamic assembly, which registry discovery skips. Nested member type names use reflection's Outer+Inner form, not the generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned (bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj excluded) found no member type name naming a nested type in either form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
registry.GetRequired(type);
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}.");
}
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);
}
}