From 67575c4ec44a68d0829f07ebe888c0ca5054e046 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 04:19:35 +0200 Subject: [PATCH] Store reader: a record whose schema the catalog does not publish is unreadable C# read such a store on flush and commit and refused it on open, with an untyped InvalidOperationException from the cache. The store reader now refuses it with CultStoreUnreadableException, so open, flush and commit give one verdict, as Rust, TypeScript and Python already do. The readability open test builds a cache with a codec, so a variant store opens as it would in use. Co-Authored-By: Claude Sonnet 5.5 --- .../CultDocumentMessagePackSerialization.cs | 23 ++++++++++++++++++- .../StoreReadabilityTests.cs | 9 ++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 579eddb6..319d8361 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -180,7 +180,8 @@ public static class CultDocumentMessagePackSerialization /// /// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty /// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that - /// is not a header string, a store with fewer or more slots, or a body that does not decode are refused with + /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body + /// that does not decode are refused with /// , 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. /// @@ -224,6 +225,7 @@ public static class CultDocumentMessagePackSerialization snapshot.Records[index] = ReadPersistedRecord(ref reader); } + RequirePublishedSchemas(snapshot); return snapshot; } catch (CultStoreUnreadableException) @@ -236,6 +238,25 @@ public static class CultDocumentMessagePackSerialization } } + // A record whose schema the store's catalog does not publish cannot be read by any runtime that has only the file. + private static void RequirePublishedSchemas(CultPersistedStoreSnapshot snapshot) + { + var published = new HashSet(StringComparer.Ordinal); + foreach (var entry in snapshot.SchemaCatalog) + { + published.Add(entry.SchemaId); + foreach (var compatible in entry.CompatibleSchemaIds) + published.Add(compatible); + } + + var unpublished = snapshot.Records.FirstOrDefault(record => !published.Contains(record.SchemaId)); + if (unpublished != null) + { + throw new CultStoreUnreadableException( + $"Record '{unpublished.Key}' names schema '{unpublished.SchemaId}', which the store's schema catalog does not publish."); + } + } + private static void RequireOneArray(byte[] bytes) { var whole = new MessagePackReader(bytes); diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 7abee9a5..7a7ddb55 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -15,7 +15,7 @@ namespace GameCult.Caching.Tests public class StoreReadabilityTests { private const int CSharp = 1; - private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck), typeof(VectorItem) }); private string _directory = ""; [SetUp] @@ -66,12 +66,7 @@ namespace GameCult.Caching.Tests var path = Path.Combine(_directory, "open.cc"); File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); - using var cache = new CultCache(); - void Open() - { - cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); - cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); - } + void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose(); if (reads) Assert.DoesNotThrow(Open);