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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 04:19:35 +02:00
commit 67575c4ec4
2 changed files with 24 additions and 8 deletions

View file

@ -180,7 +180,8 @@ public static class CultDocumentMessagePackSerialization
/// <summary>
/// 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
/// <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>
@ -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<string>(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);

View file

@ -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);