C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty payload; the cache resolves it against its base inside the hold that admits it or any record on its base chain, and re-resolves dependents in the same Apply, so a base change reaches its variants with contiguous sequences and no stamp. Plain Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing base, different concrete type, evicted base with survivors and a name that shadows the base are refused naming the keys. Overrides of a dropped slot or an undecodable value are ignored with a warning and reported as compatible drift. The store writes cultcache.store.v2 only when it holds a variant and reads v1 and v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the cache, which needs it to clone documents and overlay override values. The directory store, CultMesh single-file reads and CultNet row building refuse a variant by name until their own cuts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7584a5c08a
commit
dbc87b9447
11 changed files with 797 additions and 55 deletions
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
|
|
@ -30,8 +31,8 @@ public sealed class CultDocumentResolver : IFormatterResolver
|
|||
|
||||
public static class CultDocumentMessagePackSerialization
|
||||
{
|
||||
private const string SingleFileFormatVersion = "cultcache.store.v1";
|
||||
private const int PersistedRecordFieldCount = 4;
|
||||
private const int VariantRecordFieldCount = 5;
|
||||
private const int SchemaCatalogEntryFieldCount = 7;
|
||||
private const int SchemaCatalogMemberFieldCount = 8;
|
||||
private const int StoreSnapshotFieldCount = 3;
|
||||
|
|
@ -95,6 +96,49 @@ public static class CultDocumentMessagePackSerialization
|
|||
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member
|
||||
/// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil.
|
||||
/// </summary>
|
||||
public static byte[] OverlaySlots(byte[] payload, IReadOnlyList<KeyValuePair<int, byte[]>> slots)
|
||||
{
|
||||
var reader = new MessagePackReader(payload);
|
||||
if (reader.NextMessagePackType != MessagePackType.Array)
|
||||
{
|
||||
throw new InvalidOperationException("A document payload is a MessagePack array; this one is not, so its slots cannot be overridden.");
|
||||
}
|
||||
|
||||
var count = reader.ReadArrayHeader();
|
||||
var elements = new List<byte[]>(count);
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var start = reader.Position;
|
||||
reader.Skip();
|
||||
elements.Add(reader.Sequence.Slice(start, reader.Position).ToArray());
|
||||
}
|
||||
|
||||
foreach (var (slot, value) in slots)
|
||||
{
|
||||
while (elements.Count <= slot)
|
||||
{
|
||||
elements.Add(new byte[] { 0xc0 });
|
||||
}
|
||||
|
||||
elements[slot] = value;
|
||||
}
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
writer.WriteArrayHeader(elements.Count);
|
||||
foreach (var element in elements)
|
||||
{
|
||||
writer.WriteRaw(element);
|
||||
}
|
||||
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
public static byte[] SerializePersistedRecord(CultPersistedRecord record)
|
||||
{
|
||||
var buffer = new global::System.Buffers.ArrayBufferWriter<byte>();
|
||||
|
|
@ -141,7 +185,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
if (fieldCount > 0)
|
||||
{
|
||||
snapshot.FormatVersion = reader.ReadString()
|
||||
?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + SingleFileFormatVersion + " only.");
|
||||
?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + " and " + CultPersistedStoreSnapshot.FormatV2 + ".");
|
||||
}
|
||||
|
||||
if (fieldCount > 1)
|
||||
|
|
@ -172,24 +216,110 @@ public static class CultDocumentMessagePackSerialization
|
|||
return snapshot;
|
||||
}
|
||||
|
||||
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format it found.</summary>
|
||||
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format or record it found.</summary>
|
||||
public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot)
|
||||
{
|
||||
if (!string.Equals(snapshot.FormatVersion, SingleFileFormatVersion, StringComparison.Ordinal))
|
||||
var version = snapshot.FormatVersion;
|
||||
if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) &&
|
||||
!string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Store format {snapshot.FormatVersion} is not readable; this runtime reads {SingleFileFormatVersion} only. " +
|
||||
"The store needs a runtime that resolves document variants.");
|
||||
$"Store format {version} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1} and {CultPersistedStoreSnapshot.FormatV2}.");
|
||||
}
|
||||
|
||||
// Only a v2 store may hold a variant: a v1 header over a variant record is a store no reader could trust.
|
||||
if (string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal))
|
||||
{
|
||||
var variant = snapshot.Records.FirstOrDefault(record => record.Variant != null);
|
||||
if (variant != null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record)
|
||||
{
|
||||
writer.WriteArrayHeader(PersistedRecordFieldCount);
|
||||
writer.WriteArrayHeader(record.Variant == null ? PersistedRecordFieldCount : VariantRecordFieldCount);
|
||||
writer.Write(record.Key);
|
||||
writer.Write(record.SchemaId);
|
||||
writer.Write(record.StoredAt);
|
||||
writer.Write(record.Payload);
|
||||
if (record.Variant != null)
|
||||
{
|
||||
// Slot 4: [baseKey, overrides[]]; an override is [op, path[], id, value], a path step [slot, elementId],
|
||||
// and the value is the member's own MessagePack encoding, inline.
|
||||
writer.WriteArrayHeader(2);
|
||||
writer.Write(record.Variant.BaseKey);
|
||||
writer.WriteArrayHeader(record.Variant.Overrides.Count);
|
||||
foreach (var entry in record.Variant.Overrides)
|
||||
{
|
||||
writer.WriteArrayHeader(4);
|
||||
writer.Write((int)entry.Op);
|
||||
writer.WriteArrayHeader(entry.Path.Count);
|
||||
foreach (var step in entry.Path)
|
||||
{
|
||||
writer.WriteArrayHeader(2);
|
||||
writer.Write(step.Slot);
|
||||
writer.Write(step.ElementId);
|
||||
}
|
||||
|
||||
writer.Write(entry.Id);
|
||||
writer.WriteRaw(entry.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static CultVariantDelta ReadVariant(ref MessagePackReader reader, string key, string schemaId)
|
||||
{
|
||||
var slots = reader.ReadArrayHeader();
|
||||
if (slots != 2)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Record '{key}' (schema '{schemaId}') has a variant slot of {slots} entries; this runtime reads 2 (base key, overrides).");
|
||||
}
|
||||
|
||||
var baseKey = reader.ReadString() ?? string.Empty;
|
||||
var count = reader.ReadArrayHeader();
|
||||
var overrides = new CultVariantOverride[count];
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var fields = reader.ReadArrayHeader();
|
||||
if (fields != 4)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Record '{key}' (schema '{schemaId}') has an override of {fields} entries; this runtime reads 4 (op, path, id, value).");
|
||||
}
|
||||
|
||||
var op = reader.ReadInt32();
|
||||
if (op is < 0 or > 2)
|
||||
{
|
||||
throw new NotSupportedException($"Record '{key}' (schema '{schemaId}') has an override with unknown op {op}.");
|
||||
}
|
||||
|
||||
var stepCount = reader.ReadArrayHeader();
|
||||
var path = new CultPathStep[stepCount];
|
||||
for (var step = 0; step < stepCount; step++)
|
||||
{
|
||||
var stepFields = reader.ReadArrayHeader();
|
||||
if (stepFields != 2)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Record '{key}' (schema '{schemaId}') has a path step of {stepFields} entries; this runtime reads 2 (slot, elementId).");
|
||||
}
|
||||
|
||||
path[step] = new CultPathStep(reader.ReadInt32(), reader.ReadString() ?? string.Empty);
|
||||
}
|
||||
|
||||
var id = reader.ReadString() ?? string.Empty;
|
||||
var start = reader.Position;
|
||||
reader.Skip();
|
||||
var value = reader.Sequence.Slice(start, reader.Position).ToArray();
|
||||
overrides[index] = new CultVariantOverride((CultOverrideOp)op, path, id, value);
|
||||
}
|
||||
|
||||
return new CultVariantDelta(baseKey, overrides);
|
||||
}
|
||||
|
||||
public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader)
|
||||
|
|
@ -217,11 +347,21 @@ public static class CultDocumentMessagePackSerialization
|
|||
record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
if (fieldCount > PersistedRecordFieldCount)
|
||||
if (fieldCount > VariantRecordFieldCount)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount}. " +
|
||||
"The store needs a runtime that resolves document variants.");
|
||||
$"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount} " +
|
||||
$"(or {VariantRecordFieldCount} for a variant).");
|
||||
}
|
||||
|
||||
if (fieldCount == VariantRecordFieldCount)
|
||||
{
|
||||
record.Variant = ReadVariant(ref reader, record.Key, record.SchemaId);
|
||||
if (record.Payload.Length != 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Record '{record.Key}' (schema '{record.SchemaId}') is a variant and must carry an empty payload.");
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
|
|
|
|||
Loading…
Reference in a new issue