C#: compatible schema ids publish a schema on open, Mesh takes only the older layout, the refusal names its file
A record whose schema id the catalog lists only as a compatible id now opens (the cache resolved a persisted schema by entry id alone). CultMesh's fallback to the older catalog-entry reader fires only for a store in that layout, decided by the entry's slot 5, not for any decode failure, so a current-layout store with a malformed slot is refused as CultCache refuses it. CultStoreUnreadableException.Path names the refused file. New vectors: compatible-id-only-v3 (accepted by all four) and three current-layout catalogs with one malformed slot. The contract states the writer's invariant. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d5bc28e207
commit
969d78de39
16 changed files with 118 additions and 16 deletions
|
|
@ -234,7 +234,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
throw new CultStoreUnreadableException(ex.Message, ex);
|
||||
throw new CultStoreUnreadableException(ex.Message, innerException: ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +268,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
}
|
||||
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
|
||||
{
|
||||
throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", ex);
|
||||
throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", innerException: ex);
|
||||
}
|
||||
|
||||
if (!whole.End)
|
||||
|
|
|
|||
|
|
@ -451,7 +451,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
|
|||
};
|
||||
}
|
||||
|
||||
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
|
||||
CultPersistedStoreSnapshot snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
|
||||
}
|
||||
catch (CultStoreUnreadableException ex) when (ex.Path == null)
|
||||
{
|
||||
throw new CultStoreUnreadableException(ex.Message, _manifestFile.FullName, ex.InnerException);
|
||||
}
|
||||
|
||||
if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) &&
|
||||
!string.Equals(snapshot.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ A reader never recovers a schema from a record's payload: a record whose schema
|
|||
catalog does not publish (by id or as a compatible id) makes the store unreadable, whatever
|
||||
the payload's first field says. The catalog is the store's own description of itself.
|
||||
|
||||
A writer keeps the same invariant: every record it writes carries a schema id that some catalog
|
||||
entry it writes publishes, as that entry's id or as one of its compatible ids. A cache stamps a
|
||||
record with its registered schema's id, whatever id the record arrived under.
|
||||
|
||||
This is not decorative paperwork. It is what allows another CultCache
|
||||
implementation to inspect an old store, resolve the schema for each record, and
|
||||
decide whether it can:
|
||||
|
|
|
|||
|
|
@ -529,7 +529,9 @@ namespace GameCult.Caching
|
|||
});
|
||||
}
|
||||
|
||||
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal));
|
||||
// A schema is published by its id or as a compatible id.
|
||||
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))
|
||||
?? catalog.FirstOrDefault(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal));
|
||||
if (persisted == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
|
||||
|
|
@ -3601,7 +3603,16 @@ namespace GameCult.Caching
|
|||
private CultPersistedStoreSnapshot? ReadSnapshot()
|
||||
{
|
||||
var bytes = ReadDisk();
|
||||
return bytes == null ? null : DeserializeSnapshot(bytes);
|
||||
if (bytes == null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return DeserializeSnapshot(bytes);
|
||||
}
|
||||
catch (CultStoreUnreadableException ex) when (ex.Path == null)
|
||||
{
|
||||
throw new CultStoreUnreadableException(ex.Message, FileInfo.FullName, ex.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[]? ReadDisk()
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ namespace GameCult.Caching
|
|||
/// A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a value that is not a
|
||||
/// store, a missing or extra top-level slot), a format or record it does not know, or a body it cannot decode. Open, flush,
|
||||
/// commit and CultMesh's single-file reads all refuse a file with this one exception, and a refused file is left as it was.
|
||||
/// The underlying cause, where there is one, is the inner exception.
|
||||
/// The underlying cause, where there is one, is the inner exception, and <see cref="Path"/> names the file.
|
||||
/// </summary>
|
||||
public sealed class CultStoreUnreadableException : NotSupportedException
|
||||
{
|
||||
public CultStoreUnreadableException(string message, Exception? innerException = null)
|
||||
public CultStoreUnreadableException(string message, string? path = null, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
/// <summary>The store file that was refused, or null when the bytes were read without a file.</summary>
|
||||
public string? Path { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,10 +270,9 @@ namespace GameCult.Mesh
|
|||
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
}
|
||||
|
||||
// Every refusal is a CultStoreUnreadableException. 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
|
||||
// catalog entry the current layout cannot decode (a MessagePack or shape failure) sends it there; a store refused for its
|
||||
// framing, its slot count or a record keeps that refusal.
|
||||
// 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);
|
||||
|
|
@ -282,17 +281,55 @@ namespace GameCult.Mesh
|
|||
CultPersistedStoreSnapshot snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
|
||||
try
|
||||
{
|
||||
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
|
||||
}
|
||||
catch (CultStoreUnreadableException) when (HasOlderCatalogLayout(bytes))
|
||||
{
|
||||
snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
|
||||
}
|
||||
|
||||
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
|
||||
}
|
||||
catch (CultStoreUnreadableException ex) when (ex.InnerException is MessagePackSerializationException or InvalidOperationException)
|
||||
catch (CultStoreUnreadableException ex) when (ex.Path == null)
|
||||
{
|
||||
snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
|
||||
throw new CultStoreUnreadableException(ex.Message, path, ex.InnerException);
|
||||
}
|
||||
|
||||
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -123,6 +123,15 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
|
||||
}
|
||||
|
||||
// Slots of the current catalog layout that do not decode are refused by name, as a malformed store is.
|
||||
[TestCase("current-catalog-bad-canonical.msgpack")]
|
||||
[TestCase("current-catalog-bad-members.msgpack")]
|
||||
[TestCase("current-catalog-bad-compat.msgpack")]
|
||||
public void SingleFileRefusesAMalformedCurrentCatalogEntry(string vector)
|
||||
{
|
||||
Assert.That(Refusal(vector), Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SingleFileRefusesExtraRecordSlotNamingTheRecord()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ namespace GameCult.Caching.Tests
|
|||
if (reads)
|
||||
Assert.DoesNotThrow(Open);
|
||||
else
|
||||
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>());
|
||||
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Vectors))]
|
||||
|
|
@ -116,6 +116,7 @@ namespace GameCult.Caching.Tests
|
|||
|
||||
// A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3.
|
||||
[TestCase("../v3-base.msgpack", "cultcache.store.v3")]
|
||||
[TestCase("compatible-id-only-v3.bin", "cultcache.store.v3")]
|
||||
[TestCase("zero-byte.bin", "cultcache.store.v1")]
|
||||
[TestCase("empty-array.bin", "cultcache.store.v1")]
|
||||
public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,16 @@ public sealed class CultMeshVariantRefusalTests
|
|||
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.
|
||||
[TestCase("current-catalog-bad-canonical.msgpack")]
|
||||
[TestCase("current-catalog-bad-members.msgpack")]
|
||||
[TestCase("current-catalog-bad-compat.msgpack")]
|
||||
public void ReadSingleFileDocument_RefusesAMalformedCurrentCatalogEntry(string vector)
|
||||
{
|
||||
Refusal(vector).Path.Should().Be(VectorPath(vector));
|
||||
}
|
||||
|
||||
// Q6: CultMesh does not resolve variants. It refuses a variant key naming it and its base, and still reads the plain
|
||||
// records of the same v2 store.
|
||||
[Test]
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -12,6 +12,12 @@ at test time. Run: python generate.py (needs msgpack).
|
|||
legacy reader accepts
|
||||
legacy-catalog-trailing.msgpack legacy-catalog-plain plus three bytes after the array: one array is
|
||||
required of the legacy layout too
|
||||
current-catalog-bad-canonical.msgpack
|
||||
current-catalog-bad-members.msgpack
|
||||
current-catalog-bad-compat.msgpack
|
||||
v1 header, plain records, and the first catalog entry of the current layout with one slot
|
||||
malformed (canonical schema json an int; members an int; a compatible id an int). Not the
|
||||
older layout, so CultMesh refuses them as CultCache does
|
||||
v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib
|
||||
69a21bb (SingleFileMessagePackBackingStore.push of alpha and
|
||||
beta, type vectors.item, before any C0 code existed)
|
||||
|
|
@ -49,3 +55,10 @@ write("legacy-catalog-extra-slot.msgpack",
|
|||
write("legacy-catalog-plain.msgpack", [header, [legacy(entry) for entry in catalog], records])
|
||||
(here / "legacy-catalog-trailing.msgpack").write_bytes(
|
||||
msgpack.packb([header, [legacy(entry) for entry in catalog], records], use_bin_type=True) + b"\x01\x02\x03")
|
||||
current = catalog[0]
|
||||
for name, entry in (
|
||||
("current-catalog-bad-canonical", current[:4] + [5]),
|
||||
("current-catalog-bad-members", current[:6] + [5]),
|
||||
("current-catalog-bad-compat", current[:5] + [[7]] + current[6:]),
|
||||
):
|
||||
write(name + ".msgpack", [header, [entry] + catalog[1:], records])
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3‘—±vectors.item.next¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta
|
||||
|
|
@ -39,6 +39,8 @@ vectors = {
|
|||
"missing-schema-v3": pack([base[0], [], base[2]]),
|
||||
# The same, with each payload opening with its schema version: no runtime recovers a schema from a payload.
|
||||
"missing-schema-versioned-v3": pack([base[0], [], [record[:3] + [pack(["vectors.item.v1", "n", 1])] for record in base[2]]]),
|
||||
# The record names its schema by an id the catalog publishes only as a compatible id of an entry with another id.
|
||||
"compatible-id-only-v3": pack([base[0], [[base[1][0][0] + ".next"] + base[1][0][1:]], base[2]]),
|
||||
# v3-base plus a variant of alpha: a fifth record slot, [baseKey, overrides[]], over an empty payload, overriding the name. Only C# reads it.
|
||||
"variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", [[0, [[0, ""]], "", "gamma"]]]]]]),
|
||||
# v3-base with a fourth top-level slot after the records.
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@ extra-top-slot.bin refuses refuses refuses refuses
|
|||
missing-schema-v3.bin refuses refuses refuses refuses
|
||||
variant-slot-v3.bin reads refuses refuses refuses
|
||||
missing-schema-versioned-v3.bin refuses refuses refuses refuses
|
||||
compatible-id-only-v3.bin reads reads reads reads
|
||||
|
|
|
|||
Loading…
Reference in a new issue