diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 2ca2e9e4..4b7de893 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -206,10 +206,10 @@ The rule is checked again where the write lands, under the store's lock: a write whose durable record is not owned, because another writer rewrote it after this cache loaded, is refused typed, and the record is reloaded as the store holds it, read-only when a type can read it. This holds for every write through the store, the store's own `Push`, `Delete` and -`CommitBatch` included. A CultMesh single-file write asks the same rule with the one catalog entry it writes in -place of a registry: it owns that entry's own id and the ids it lists compatible, and an id whose publishing -entry that entry reads whole (the entry lists the writer's id compatible, or has its schema name, and leaves no -persisted slot unread); it is refused the same way otherwise. +`CommitBatch` included. A CultMesh single-file write asks the same rule: it owns its entry's own id and the ids that entry lists +compatible, and an id whose publishing entry its claimant reads whole (no persisted slot unread). The claimant is +the one a cache with the call's registry would pick, so a cacheless write is never more permissive than a cache; +with no registry the call's one entry is the claimant. It is refused the same way otherwise. A record that resolves to no local type is a foreign record, for example one of a type this build does not have, or one renamed under a new schema name without declaring its old id. A store never destroys or relabels it: the store carries it diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 3a59f769..72e90169 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -569,6 +569,38 @@ namespace GameCult.Caching new(Key, StoredAt, Descriptor, delta, document, codec) { IdsInMemoryOnly = idsInMemoryOnly, StoredSchemaId = StoredSchemaId, StoredVersion = StoredVersion }; } + // What the one claimant precedence (CultDocumentRegistry.Claimant) asks of the entries a reader holds: the entry owning an id, + // the entries listing an id compatible, the entries of a schema name, and how to name one in a refusal. + internal interface IClaimantLookup where T : class + { + T? Owner(string schemaId); + IReadOnlyList Listing(string schemaId); + IReadOnlyList Named(string schemaName); + ClaimantFacts Describe(T claimant); + } + + internal readonly record struct ClaimantFacts(string SchemaName, string SchemaVersion, string TypeLabel); + + // The lookup of a cacheless writer without a registry: the catalog entries the call writes. + internal sealed class CatalogEntryLookup : IClaimantLookup + { + private readonly IReadOnlyCollection _entries; + + public CatalogEntryLookup(IReadOnlyCollection entries) => _entries = entries; + + public CultSchemaCatalogEntry? Owner(string schemaId) => + _entries.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)); + + public IReadOnlyList Listing(string schemaId) => + _entries.Where(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal)).ToArray(); + + public IReadOnlyList Named(string schemaName) => + _entries.Where(entry => string.Equals(entry.SchemaName, schemaName, StringComparison.Ordinal)).ToArray(); + + public ClaimantFacts Describe(CultSchemaCatalogEntry claimant) => + new(claimant.SchemaName, claimant.SchemaVersion, claimant.SchemaId); + } + public sealed class CultDocumentRegistry { private static readonly Lazy SharedRegistry = @@ -703,11 +735,21 @@ namespace GameCult.Caching internal CultDocumentDescriptor? Claimant( string schemaId, IReadOnlyCollection catalog, + out CultSchemaCatalogEntry? persisted) => + Claimant(schemaId, catalog, _indexes, out persisted); + + // The only claimant precedence: which entry reads a persisted catalog entry, over a lookup of the entries a reader holds (a + // registry's types, or the entries a cacheless writer writes). Exact id, then the one entry listing the id, then a local id the + // persisted entry lists, then the schema name. Every caller, cache or cacheless, asks it; none writes a precedence of its own. + internal static T? Claimant( + string schemaId, + IReadOnlyCollection catalog, + IClaimantLookup lookup, out CultSchemaCatalogEntry? persisted) + where T : class { - var indexes = _indexes; persisted = null; - if (indexes.BySchemaId.TryGetValue(schemaId, out var exact)) + if (lookup.Owner(schemaId) is { } exact) return exact; persisted = PublishedBy(schemaId, catalog); @@ -717,33 +759,35 @@ namespace GameCult.Caching } // No type owns the id, so the one type that lists it is the record's type. Two listers and no owner cannot say which. - if (indexes.ByCompatibleSchemaId.TryGetValue(schemaId, out var listers)) + var listers = lookup.Listing(schemaId); + if (listers.Count > 0) { - if (listers.Length > 1) + if (listers.Count > 1) throw new CultSchemaConflictException( $"CultCache schema id '{schemaId}' is owned by no registered type and declared compatible by several " + - $"({string.Join(", ", listers.Select(lister => lister.DocumentType.FullName))}), so a record under it resolves to none. " + + $"({string.Join(", ", listers.Select(lister => lookup.Describe(lister).TypeLabel))}), so a record under it resolves to none. " + "Register the type that owns the id, or declare it on one type.", schemaId, - listers.Select(lister => lister.SchemaName).ToArray(), + listers.Select(lister => lookup.Describe(lister).SchemaName).ToArray(), string.Empty); return listers[0]; } foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) { - if (indexes.BySchemaId.TryGetValue(compatibleSchemaId, out var compatibleLocal)) + if (lookup.Owner(compatibleSchemaId) is { } compatibleLocal) return compatibleLocal; } - if (indexes.BySchemaName.TryGetValue(persisted.SchemaName, out var localVersions)) + var localVersions = lookup.Named(persisted.SchemaName); + if (localVersions.Count > 0) { - if (localVersions.Length == 1) + if (localVersions.Count == 1) return localVersions[0]; throw new InvalidOperationException( $"CultCache schema name '{persisted.SchemaName}' is ambiguous across local versions " + - $"[{string.Join(", ", localVersions.Select(candidate => candidate.SchemaVersion).OrderBy(version => version, StringComparer.Ordinal))}]. " + + $"[{string.Join(", ", localVersions.Select(candidate => lookup.Describe(candidate).SchemaVersion).OrderBy(version => version, StringComparer.Ordinal))}]. " + "Persisted schema compatibility must identify an explicit schema id."); } @@ -761,43 +805,36 @@ namespace GameCult.Caching // next write. A record read only by dropping slots is not owned: declaring its id compatible is the consent to the loss. // Header only: the persisted catalog entry that publishes the id is compared with the claimant's entry, never a payload. A // claimant that cannot be named (ambiguous, id missing from the catalog, shapes incompatible) owns nothing. - internal bool Owns(string schemaId, IReadOnlyCollection catalog) => - Owns( - schemaId, - catalog, - Declared, - _ => - { - try - { - return Claimant(schemaId, catalog, out _)?.ToCatalogEntry(); - } - catch (InvalidOperationException) - { - return null; - } - }); + internal bool Owns(string schemaId, IReadOnlyCollection catalog) => Owns(schemaId, catalog, Declared); - // The rule itself, for any writer: declared, or the entry that reads the record's published entry (the writer's claimant) - // reads it whole. A registry supplies Declared and Claimant; a cacheless writer supplies its one entry through the same two. - internal static bool Owns( + // The same rule with the declared half supplied: a cacheless writer lent this registry declares what it writes, and claims + // with the types this registry would. + internal bool Owns(string schemaId, IReadOnlyCollection catalog, Func declared) => + Owns(schemaId, catalog, declared, _indexes, descriptor => descriptor.ToCatalogEntry()); + + // The rule itself, for any writer: declared, or the claimant (picked by the one Claimant over a lookup) reads the record's + // published entry whole. The lookup is a registry's indexes, or the entries a registry-less cacheless writer writes. + internal static bool Owns( string schemaId, IReadOnlyCollection catalog, Func declared, - Func claimantOf) + IClaimantLookup lookup, + Func entryOf) + where T : class { if (declared(schemaId)) return true; - var persisted = PublishedBy(schemaId, catalog); - return persisted != null && claimantOf(persisted) is { } claimant && ReadsWhole(persisted, claimant); + try + { + var claimant = Claimant(schemaId, catalog, lookup, out var persisted); + return persisted != null && claimant != null && ReadsWhole(persisted, entryOf(claimant)); + } + catch (InvalidOperationException) + { + return false; + } } - // The entry a cacheless writer reads a published entry with: the one that lists the writer's id compatible, else the one of - // the same schema name, as Claimant picks a type. - internal static CultSchemaCatalogEntry? ClaimantEntry(IEnumerable writes, CultSchemaCatalogEntry persisted) => - writes.FirstOrDefault(entry => persisted.CompatibleSchemaIds.Contains(entry.SchemaId, StringComparer.Ordinal)) - ?? writes.FirstOrDefault(entry => string.Equals(entry.SchemaName, persisted.SchemaName, StringComparison.Ordinal)); - // Owns' declared half: a registered type owns the id or lists it compatible. internal bool Declared(string schemaId) { @@ -879,7 +916,7 @@ namespace GameCult.Caching new[] { existing.SchemaName, claimed.SchemaName }, string.Empty); - private sealed class RegistryIndexes + private sealed class RegistryIndexes : IClaimantLookup { public RegistryIndexes() { @@ -902,6 +939,17 @@ namespace GameCult.Caching // Every type declaring the id compatible, in registration order; resolution never depends on that order. public Dictionary ByCompatibleSchemaId { get; } public Dictionary BySchemaName { get; } + + public CultDocumentDescriptor? Owner(string schemaId) => BySchemaId.GetValueOrDefault(schemaId); + + public IReadOnlyList Listing(string schemaId) => + ByCompatibleSchemaId.GetValueOrDefault(schemaId) ?? Array.Empty(); + + public IReadOnlyList Named(string schemaName) => + BySchemaName.GetValueOrDefault(schemaName) ?? Array.Empty(); + + public ClaimantFacts Describe(CultDocumentDescriptor claimant) => + new(claimant.SchemaName, claimant.SchemaVersion, claimant.DocumentType.FullName ?? string.Empty); } private static CultDocumentDescriptor BuildDescriptor(Type type) @@ -4398,7 +4446,7 @@ namespace GameCult.Caching /// The record to store. /// The schema catalog entry for the record's schema id. /// Whether the record's payload holds element ids, or null when the caller cannot tell. - /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read; omitted, it decides nothing for them. It serves this call only: the store keeps no registry. + /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read, and the registry also picks the type that reads a durable record, as a cache registering it would; omitted, it decides nothing for them and the one catalog entry is the reader. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws . /// The store is attached to a cache; write through the cache. /// The file at the path is not a store this runtime can read. @@ -4651,21 +4699,21 @@ namespace GameCult.Caching _staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals).Concat(checks).Concat(StagedChecks), OwnsOnDisk(upserts)); - // The owns predicate a write over the durable catalog asks. A cacheless writer has no registry: it owns what its one entry - // declares, or reads whole in the entry that publishes the id. + // The owns predicate a write over the durable catalog asks. A cacheless writer declares what the entries it writes declare + // (variants:ruling:mesh-writes-bound-by-declaration), and reads a published entry with the claimant a cache registering the + // same types would pick: its lent registry's, else the entries it writes. It is never more permissive than that cache. private Func, bool> OwnsOnDisk(IReadOnlyCollection? upserts = null) => Cache != null || upserts == null ? Registry.Owns : (id, catalog) => { var writes = upserts.Select(staged => staged.Entry).ToArray(); - return CultDocumentRegistry.Owns( - id, - catalog, - declared => writes.Any(entry => - string.Equals(entry.SchemaId, declared, StringComparison.Ordinal) || - entry.CompatibleSchemaIds.Contains(declared, StringComparer.Ordinal)), - persisted => CultDocumentRegistry.ClaimantEntry(writes, persisted)); + Func declared = candidate => writes.Any(entry => + string.Equals(entry.SchemaId, candidate, StringComparison.Ordinal) || + entry.CompatibleSchemaIds.Contains(candidate, StringComparer.Ordinal)); + return AttachedRegistry is { } lent + ? lent.Owns(id, catalog, declared) + : CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes), entry => entry); }; // A refused key's durable version is what this store has now read of it. diff --git a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs index c3de7dc6..f7170bfd 100644 --- a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs +++ b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs @@ -53,6 +53,14 @@ namespace GameCult.Caching.Tests [Key(1)] public int Value; } + // Reads a Narrow-shaped entry only by dropping Value. + [CultDocument("tests.restamp_short", "tests.restamp_short.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteShort + { + [Key(0)] [CultName] public string Name = string.Empty; + } + // Grown with the lossy id declared: declaration is the consent to the loss. [CultDocument("tests.restamp_note", "tests.restamp_note.v4", CompatibleSchemaIds = new[] { WideId })] [MessagePackObject(AllowPrivate = true)] @@ -344,7 +352,7 @@ namespace GameCult.Caching.Tests return path; } - // Variants live in a single file (the directory store refuses them). The staging half asks the flag ToStoredDocument set on a loaded variant, and Resolved and Flatten carry it on: an owned + // Variants live in a single file (the directory store refuses them). The staging half asks the flag ToStoredDocument set on a loaded variant; Resolved and Flatten copies are never asked (only the store's loaded view carries the read-whole flag): an owned // drift variant is rewritten, flattened and removed like a plain drift record, restamping only what is written. [Test] public async Task AnOwnedDriftVariantIsRewritten() @@ -390,16 +398,19 @@ namespace GameCult.Caching.Tests } // A store whose entry is a newer writer's rename of Narrow's schema, with identical members and the given compatible ids. - private string SeedRenamed(bool listsNarrow) + private string SeedRenamed(bool listsNarrow, string? alsoLists = null, string name = "tests.restamp_note_renamed") { var path = SeedNarrow(directory: false); var snapshot = Snapshot(path); var narrowId = NarrowRegistry.GetRequired().SchemaId; var entry = snapshot.SchemaCatalog.Single(); entry.SchemaId = entry.ContentHash = "tests.restamp.renamed"; - entry.SchemaName = "tests.restamp_note_renamed"; + entry.SchemaName = name; entry.SchemaVersion = "tests.restamp_note_renamed.v2"; - entry.CompatibleSchemaIds = listsNarrow ? new[] { "tests.restamp.renamed", narrowId } : new[] { "tests.restamp.renamed" }; + entry.CompatibleSchemaIds = new[] { "tests.restamp.renamed" } + .Concat(listsNarrow ? new[] { narrowId } : Array.Empty()) + .Concat(alsoLists == null ? Array.Empty() : new[] { alsoLists }) + .ToArray(); foreach (var record in snapshot.Records) record.SchemaId = entry.SchemaId; File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); @@ -407,15 +418,16 @@ namespace GameCult.Caching.Tests } // One writer of NoteNarrow, with a cache or without one: the same Owns answer for the same record. - private void WriteNarrow(string path, bool cacheless) + private void WriteNarrow(string path, bool cacheless, CultDocumentRegistry? registry = null) { + registry ??= NarrowRegistry; if (cacheless) { - new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteNarrow { Name = "note", Value = 2 }, registry: NarrowRegistry); + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteNarrow { Name = "note", Value = 2 }, registry: registry); return; } - using var cache = Open(path, false, NarrowRegistry); + using var cache = Open(path, false, registry); cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "note", Value = 2 }, new CultRecordHandle(Note))), Is.True); } @@ -455,6 +467,54 @@ namespace GameCult.Caching.Tests Assert.That(Fingerprint(path), Is.EqualTo(before)); } + private static readonly CultDocumentRegistry NarrowShortRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteShort) }); + + // Which entry reads a persisted entry is one precedence: a cacheless writer lent a registry picks the claimant that registry + // picks, never one by schema name over its own entry. Here the entry is named and shaped like Narrow but lists Short's id. + [TestCase(false)] + [TestCase(true)] + public void ACachelessWriterPicksTheClaimantItsRegistryPicks(bool cacheless) + { + var path = SeedRenamed(listsNarrow: false, alsoLists: NarrowShortRegistry.GetRequired().SchemaId, name: "tests.restamp_note"); + var before = Fingerprint(path); + + Assert.Throws(() => WriteNarrow(path, cacheless, NarrowShortRegistry)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + [TestCase(false)] + [TestCase(true)] + public void ACachelessWriterOwnsWhatItsRegistryOwns(bool cacheless) + { + var path = SeedRenamed(listsNarrow: false, alsoLists: NarrowShortRegistry.GetRequired().SchemaId, name: "tests.restamp_note"); + + WriteNarrow(path, cacheless, NarrowRegistry); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + // A durable record under an id no catalog entry publishes belongs to nobody a writer can name: another key's write carries it + // untouched, a write at its own key is refused. + [Test] + public void AnUnpublishedIdIsCarriedByACachelessWrite() + { + var path = SeedNarrow(directory: false); + var snapshot = Snapshot(path); + var stray = new CultPersistedRecord { Key = "stray", SchemaId = "tests.restamp.unpublished", StoredAt = snapshot.Records[0].StoredAt, Payload = new byte[] { 0x90 } }; + snapshot.Records = snapshot.Records.Concat(new[] { stray }).ToArray(); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + new SingleFileMessagePackBackingStore(path).WriteDocument(Other, new NoteNarrow { Name = "other", Value = 3 }, registry: NarrowRegistry); + + var carried = Snapshot(path).Records.Single(record => record.Key == "stray"); + Assert.That(carried.SchemaId, Is.EqualTo("tests.restamp.unpublished")); + Assert.That(carried.Payload, Is.EqualTo(new byte[] { 0x90 })); + var before = Fingerprint(path); + Assert.Throws(() => + new SingleFileMessagePackBackingStore(path).WriteDocument(new CultRecordKey("stray"), new NoteNarrow { Name = "stray", Value = 1 }, registry: NarrowRegistry)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + private static string VectorPath(string name) { for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)