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:
parent
17b0288e42
commit
4a14f17270
8 changed files with 59 additions and 18 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue