Mesh single-file helpers: delete the second reader, the older catalog layout and the lock-free replace

Deletes CultMesh's ReadSingleFileSnapshot, HasOlderCatalogLayout, ReadLegacySchemaCatalogEntry,
WriteFileAtomically and the File.Exists gate, ReadStore's entry-reader delegate, and the three tests
of the older catalog layout. This commit does not build: the Mesh read and write sites still name
the deleted reader; the next commit routes them through the CultCache single-file store.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 01:59:13 +02:00
commit 2e12e9eebb
4 changed files with 4 additions and 193 deletions

View file

@ -185,13 +185,7 @@ public static class CultDocumentMessagePackSerialization
/// <see cref="CultStoreUnreadableException"/>, so a reader never takes part of a file for the whole and never skips a slot
/// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced.
/// </summary>
public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) => ReadStore(payload, ReadSchemaCatalogEntry);
internal delegate CultSchemaCatalogEntry CatalogEntryReader(ref MessagePackReader reader);
// The one store reader. A caller with an older catalog layout supplies its own entry reader; the framing, the slot count
// and the records are judged here, once.
internal static CultPersistedStoreSnapshot ReadStore(byte[] payload, CatalogEntryReader readCatalogEntry)
public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload)
{
try
{
@ -215,7 +209,7 @@ public static class CultDocumentMessagePackSerialization
snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount];
for (var index = 0; index < catalogCount; index++)
{
snapshot.SchemaCatalog[index] = readCatalogEntry(ref reader);
snapshot.SchemaCatalog[index] = ReadSchemaCatalogEntry(ref reader);
}
var recordCount = reader.ReadArrayHeader();

View file

@ -1,11 +1,9 @@
using System;
using System.Buffers;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
namespace GameCult.Mesh
{
@ -241,9 +239,7 @@ namespace GameCult.Mesh
throw new ArgumentException("Catalog entry must include a schema id.", nameof(catalogEntry));
payload ??= Array.Empty<byte>();
// The file being replaced is read first, by the reader every read of it uses: one this runtime cannot read refuses the
// write instead of being overwritten.
var existing = File.Exists(path) ? ReadSingleFileSnapshot(path) : null;
CultPersistedStoreSnapshot? existing = null;
// A typed write sees the document it replaces the file with, so its ids decide. A raw payload is opaque: the writer
// cannot see whether it holds ids, so a file already marked stays marked.
@ -263,96 +259,6 @@ namespace GameCult.Mesh
}
};
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
}
// Every refusal is a CultStoreUnreadableException naming the file. A store whose catalog entries are in the older layout is
// read by the same store reader with the older entry reader, so it is judged by the same framing, slot count and records.
// Only a store that is in the older layout goes there; any other refusal stands as the current reader gave it.
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
{
var bytes = File.ReadAllBytes(path);
CultPersistedStoreSnapshot snapshot;
try
{
try
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
}
catch (CultStoreUnreadableException) when (HasOlderCatalogLayout(bytes))
{
snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
}
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
}
catch (CultStoreUnreadableException ex) when (ex.Path == null)
{
throw new CultStoreUnreadableException(ex.Message, path, ex.InnerException);
}
return snapshot;
}
// The older layout puts the content hash, a string, at catalog entry slot 5, where the current layout has the array of
// compatible schema ids.
private static bool HasOlderCatalogLayout(byte[] bytes)
{
try
{
var reader = new MessagePackReader(bytes);
if (reader.ReadArrayHeader() != 3)
return false;
reader.Skip();
var entries = reader.ReadArrayHeader();
for (var entry = 0; entry < entries; entry++)
{
var fields = reader.ReadArrayHeader();
for (var field = 0; field < fields; field++)
{
if (field == 5 && reader.NextMessagePackType == MessagePackType.String)
return true;
reader.Skip();
}
}
return false;
}
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
{
return false;
}
}
private static CultSchemaCatalogEntry ReadLegacySchemaCatalogEntry(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.CanonicalSchemaJson = reader.ReadString() ?? string.Empty;
if (fieldCount > 4) reader.Skip();
if (fieldCount > 5) entry.ContentHash = reader.ReadString() ?? string.Empty;
if (fieldCount > 6)
{
var memberCount = reader.ReadArrayHeader();
for (var index = 0; index < memberCount; index++)
reader.Skip();
}
for (var index = 7; index < fieldCount; index++)
reader.Skip();
entry.CompatibleSchemaIds = string.IsNullOrWhiteSpace(entry.SchemaId)
? Array.Empty<string>()
: new[] { entry.SchemaId };
entry.Members = Array.Empty<CultSchemaMemberCatalogEntry>();
return entry;
}
private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId)
@ -361,14 +267,5 @@ namespace GameCult.Mesh
string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal) ||
entry.CompatibleSchemaIds.Any(candidate => string.Equals(candidate, schemaId, StringComparison.Ordinal)));
}
private static void WriteFileAtomically(string path, byte[] bytes)
{
var tempPath = path + ".tmp";
File.WriteAllBytes(tempPath, bytes);
if (File.Exists(path))
File.Delete(path);
File.Move(tempPath, path);
}
}
}

View file

@ -2585,34 +2585,6 @@ public sealed class CultMeshStreamingTests
cache.Get<MeshPublicationNoteDocument>(key)!.Text.Should().Be("published-directly");
}
[Test]
public void SingleFileDocumentHelpers_ReadLegacySingleDocumentSnapshots()
{
using var directory = new TemporaryDirectory();
var filePath = Path.Combine(directory.Path, "legacy-single-document.ccmp");
var key = new CultRecordKey("mesh-note:legacy-single-document");
var descriptor = CultDocumentRegistry.Shared.GetRequired<MeshPublicationNoteDocument>();
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(new MeshPublicationNoteDocument
{
Schema = "tests.mesh_publication_note.v1",
Text = "legacy-published",
Revision = 4
}, typeof(MeshPublicationNoteDocument));
File.WriteAllBytes(filePath, WriteLegacySingleDocumentSnapshot(
key.Value,
descriptor.SchemaId,
descriptor.SchemaName,
descriptor.SchemaVersion,
"2026-06-27T12:00:00.0000000Z",
payload));
var document = CultMesh.ReadSingleFileDocument<MeshPublicationNoteDocument>(filePath, key);
document.Text.Should().Be("legacy-published");
document.Revision.Should().Be(4);
}
[Test]
public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments()
{
@ -3853,39 +3825,6 @@ public sealed class CultMeshStreamingTests
ring.Stats().UnavoidableCopyCount.Should().Be(1);
}
private static byte[] WriteLegacySingleDocumentSnapshot(
string key,
string schemaId,
string schemaName,
string schemaVersion,
string storedAt,
byte[] payload)
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(3);
writer.Write("cultcache.store.v1");
writer.WriteArrayHeader(1);
writer.WriteArrayHeader(7);
writer.Write(schemaId);
writer.Write(schemaName);
writer.Write(schemaVersion);
writer.Write("Legacy Aetheria single document");
writer.Write(storedAt);
writer.Write("legacy-hash");
writer.WriteArrayHeader(0);
writer.WriteArrayHeader(1);
writer.WriteArrayHeader(4);
writer.Write(key);
writer.Write(schemaId);
writer.Write(storedAt);
writer.Write(payload);
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static CultMeshStreamCatalog CatalogWithByteStream()
{
var catalog = CultMesh.CreateStreamCatalog();

View file

@ -38,26 +38,7 @@ public sealed class CultMeshVariantRefusalTests
message.Should().Contain("item:anvil").And.Contain(ItemSchemaId);
}
[Test]
public void ReadSingleFileDocument_RefusesExtraRecordSlotInTheLegacyCatalogLayoutToo()
{
var message = Refusal("legacy-catalog-extra-slot.msgpack").Message;
message.Should().Contain("item:anvil").And.Contain(ItemSchemaId);
}
// The older catalog layout is read by the same store reader, so it is held to one array too: the same store with bytes after it
// is refused, and only the bytes make the difference.
[Test]
public void ReadSingleFileDocument_ReadsTheLegacyCatalogLayoutButNotWithBytesAfterIt()
{
CultMesh.ReadSingleFileDocumentPayload(VectorPath("legacy-catalog-plain.msgpack"), new CultRecordKey("item:bellows"), ItemSchemaId)
.Should().NotBeEmpty();
Refusal("legacy-catalog-trailing.msgpack").Message.Should().Contain("after its MessagePack array");
}
// A store in the current layout with one catalog slot malformed is refused as CultCache refuses it. It is not the older layout,
// so it is not read with the older reader, which would take the malformed canonical schema slot for a slot to skip.
// A store in the current layout with one catalog slot malformed is refused as CultCache refuses it.
[TestCase("current-catalog-bad-canonical.msgpack")]
[TestCase("current-catalog-bad-members.msgpack")]
[TestCase("current-catalog-bad-compat.msgpack")]