Public DeserializeSnapshot parses any stamp as in 1.0.61; stores judge the v1/v2 stamp in their own read paths; flush prose states the exact rule; the I/O refusal test asserts no write was reached

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-22 18:32:34 +02:00
commit 4a14f17270
8 changed files with 59 additions and 18 deletions

View file

@ -183,11 +183,12 @@ public static class CultDocumentMessagePackSerialization
}
/// <summary>
/// Reads a single-file snapshot. The stamp in slot 0 is checked against the formats this runtime reads (v1, v2) before
/// the catalog or any record is parsed, so a store stamped for another reader is refused, never mistaken for damage.
/// Parses a store snapshot of any format stamp, as every release has. It judges nothing about readability: a store
/// reading its own file checks the stamp against the formats it reads, before the body is parsed.
/// </summary>
public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) =>
DeserializeSnapshot(payload, RequireReadableSingleFileStamp);
DeserializeSnapshot(payload, _ => { });
// The stamp decides readability and the body never does: requireReadable runs on slot 0 before anything else is parsed.
internal static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload, Action<string> requireReadable)
@ -252,7 +253,7 @@ public static class CultDocumentMessagePackSerialization
}
}
private static void RequireReadableSingleFileStamp(string version)
internal static void RequireReadableSingleFileStamp(string version)
{
if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) &&
!string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal))
@ -560,7 +561,7 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data, CultDocumentMessagePackSerialization.RequireReadableSingleFileStamp);
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
return snapshot;
}

View file

@ -11,5 +11,6 @@
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
<InternalsVisibleTo Include="GameCult.Mesh" />
</ItemGroup>
</Project>

View file

@ -3541,9 +3541,10 @@ namespace GameCult.Caching
}
// A plain flush replaces the file without merging it, so under the lock it first reads the disk the way attach and
// commit do: a store this runtime does not read (a newer format, or a header the reader refuses) is refused and left
// as it is, and so is a file that cannot be read (an I/O or access error). A file that is read but will not parse
// (empty, truncated, corrupt) is not a newer store, and the flush proceeds as it always has, so it can still heal.
// commit do: a store whose stamp this runtime does not read is refused and left as it is, and so is a file that
// cannot be read (an I/O or access error). A file whose stamp reads as v1 or v2, or cannot be read at all (empty,
// truncated), is not a newer store, and the flush proceeds as it always has, so it can still heal; that includes a
// v1 file whose body is corrupt or truncated.
private void RefuseToRewriteAStoreItCannotRead()
{
var bytes = ReadFileBytes();

View file

@ -288,7 +288,7 @@ namespace GameCult.Mesh
CultPersistedStoreSnapshot snapshot;
try
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes, CultDocumentMessagePackSerialization.RequireReadableSingleFileStamp);
}
catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException)
{

View file

@ -428,7 +428,7 @@ namespace GameCult.Caching.Tests
await seed.FlushAsync();
}
var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath), _ => { });
var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
Assert.That(manifest.Records, Has.Length.EqualTo(2));
Assert.That(manifest.Records.All(record => record.Payload.Length == 32), Is.True,
@ -730,7 +730,7 @@ namespace GameCult.Caching.Tests
}
Assert.That(store.IsDirty, Is.False);
var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath), _ => { });
var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
Assert.That(compacted.SchemaCatalog, Is.Empty);
Assert.That(compacted.Records, Is.Empty);
var reader = new CultCache();

View file

@ -223,7 +223,7 @@ namespace GameCult.Caching.Tests
}
private CultPersistedRecord ManifestRecord(CultRecordKey key) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_manifest), _ => { }).Records.Single(record => record.Key == key.Value);
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_manifest)).Records.Single(record => record.Key == key.Value);
private string PagePath(byte[] identity) =>
Path.Combine(_records, BitConverter.ToString(identity).Replace("-", string.Empty).ToLowerInvariant() + ".msgpack");

View file

@ -47,7 +47,7 @@ namespace GameCult.Caching.Tests
try
{
var manifest = Path.Combine(root, "store.cc");
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest), _ => { }).FormatVersion,
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest)).FormatVersion,
Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
using var cache = new CultCache();
@ -413,6 +413,40 @@ namespace GameCult.Caching.Tests
}
}
// The refusal comes from the read, not from a failed write onto the held file: the store never reaches serialising.
private sealed class WriteCountingStore : SingleFileMessagePackBackingStore
{
public int Serialisations;
public WriteCountingStore(string path) : base(path) { }
protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
{
Serialisations++;
return base.SerializeSnapshot(snapshot);
}
}
// The public parser judges no stamp, as in 1.0.61: a v4 directory manifest and a v3 header both parse.
[Test]
public void PublicParserReadsAnyStampAsEveryReleaseHas()
{
var root = CopyFixture("directory-v4");
try
{
var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(root, "store.cc")));
Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
var v3 = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v3", SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(), Records = Array.Empty<CultPersistedRecord>() };
var parsed = CultDocumentMessagePackSerialization.DeserializeSnapshot(CultDocumentMessagePackSerialization.SerializeSnapshot(v3));
Assert.That(parsed.FormatVersion, Is.EqualTo("cultcache.store.v3"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// A read that fails on I/O is not an unreadable stamp to heal over: the flush refuses and the file is untouched.
[Test]
public async Task SingleFileFlushRefusesWhenReadingTheDiskFailsOnIO()
@ -422,15 +456,18 @@ namespace GameCult.Caching.Tests
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
var store = new WriteCountingStore(file);
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
var before = File.ReadAllBytes(file);
await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" });
store.Serialisations = 0;
using (new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
Assert.Throws<IOException>(() => cache.FlushAllBackingStores());
}
Assert.That(store.Serialisations, Is.Zero, "the refusal is the read failing, not the write onto the held file failing");
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
finally
@ -451,7 +488,7 @@ namespace GameCult.Caching.Tests
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await cache.PullAllBackingStoresAsync();
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest), _ => { });
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest));
snapshot.FormatVersion = newer;
File.WriteAllBytes(manifest, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
Func<System.Collections.Generic.Dictionary<string, byte[]>> files = () => Directory

View file

@ -103,9 +103,10 @@ All notable changes to this package are documented in this file.
reader refuses. A read that fails with an I/O or access error also refuses. It used to
overwrite the file from this cache's older view. A directory store throws the same
exception, from the manifest parse every write already ran, for a manifest stamped
`cultcache.store.v<N>` with N > 4 (it threw `InvalidOperationException`). A file with no
readable stamp at all (empty, truncated, or not a snapshot) is not a newer format, and a flush
rewrites it.
`cultcache.store.v<N>` with N > 4 (it threw `InvalidOperationException`). A flush
rewrites any file whose stamp it can read as v1 or v2, or cannot read at all (empty,
truncated, not a snapshot), which includes a v1 file whose body is corrupt or truncated.
It refuses a recognised newer stamp, and it refuses an I/O failure on the read.
- `CultMesh.WriteSingleFileDocument` and `WriteSingleFileDocumentPayload` read the
file they replace, under the store's `<path>.lock`, and refuse (without touching
it) a file this runtime cannot read, a store stamped with a newer format version,