diff --git a/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs b/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs index b87d5926..3ef2816d 100644 --- a/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs +++ b/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs @@ -494,38 +494,38 @@ namespace GameCult.Caching.Tests Assert.That(Extra(reopened.Get(RollY)), Is.EqualTo("derived-from-important")); } - // v1 writes k itself: a write of the record, so it stores what v1 holds, under v1's id and without the member v1 lacks. - private void OlderWritesK() - { - using var older = OpenRoll(RollV1); - older.Commit(batch => batch.Upsert(RollV1, older.Get(RollK)!, RollK)); - } - - // v1 writes k and sheds Extra, then another v2 cache (a restart is enough) loads k under v1's id and writes another record. - // It copies k as v1 left it, under v1's id, so the first v2 cache's condition on k fails, through commits and through plain - // flushes alike. - [TestCase(false, false)] - [TestCase(true, false)] - [TestCase(false, true)] - [TestCase(true, true)] - public void AnotherNewerCacheRestoringTheIdAfterAnOlderRewriteStillFailsTheCondition(bool unchangedOnly, bool viaFlush) + // v1 reads v2's k through v2's entry, which lists v1's id: the list is the writer's statement, not v1's declaration, so to v1 + // k is read-only. v1's write of k is refused typed before anything is staged, the file is left byte for byte, and a v2 + // cache's condition on k still holds. + [Test] + public void AnOlderVersionCannotWriteANewerRecordItReadsThroughTheNewerEntry([Values] bool commit) { WriteImportant(); - var written = Read(RollPath).Records.Single(); - using var held = OpenRoll(RollV2); - var current = held.Get(RollK)!; + var before = File.ReadAllBytes(RollPath); + using var newer = OpenRoll(RollV2); + var current = newer.Get(RollK)!; - OlderWritesK(); - Assert.That(Read(RollPath).Records.Single().SchemaId, Is.EqualTo(RollV1Id), "v1 wrote k under its id"); - UnrelatedWrite(RollV2, viaFlush); - var copied = Read(RollPath).Records.Single(); - Assert.That(copied.SchemaId, Is.EqualTo(RollV1Id), "the second v2 cache copied k under v1's id"); - Assert.That(copied.Payload, Is.Not.EqualTo(written.Payload), "without Extra"); + using (var older = OpenRoll(RollV1)) + { + var k = older.Get(RollK)!; + Assert.That(EmittedDocumentTypes.Read(k, "Name"), Is.EqualTo("k"), "v1 reads k"); + var refusal = Assert.Throws(() => + { + if (commit) + older.Commit(batch => batch.Upsert(RollV1, k, RollK)); + else + older.UpsertAsync(RollV1, k, RollK).GetAwaiter().GetResult(); + })!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((RollK.Value, Read(RollPath).Records.Single().SchemaId))); + Assert.That(older.BackingStores.Single().IsDirty, Is.False, "nothing was staged"); + older.FlushAsync().GetAwaiter().GetResult(); + } - Assert.That(Derive(held, current, unchangedOnly), Is.EqualTo(CultCommitOutcome.Mismatch)); + Assert.That(File.ReadAllBytes(RollPath), Is.EqualTo(before)); + Assert.That(Derive(newer, current, unchangedOnly: false), Is.EqualTo(CultCommitOutcome.Committed)); using var reopened = OpenRoll(RollV2); - Assert.That(reopened.Get(RollY), Is.Null, "nothing derived from the shed member landed"); - Assert.That(Extra(reopened.Get(RollK)), Is.Empty); + Assert.That(Extra(reopened.Get(RollK)), Is.EqualTo("important")); + Assert.That(Extra(reopened.Get(RollY)), Is.EqualTo("derived-from-important")); } // A record a write copies keeps its storedAt, so a condition another cache holds on it survives an unrelated write. diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index cfed4d27..c5716975 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -248,6 +248,34 @@ namespace GameCult.Caching.Tests Assert.That(reader.MintElementIds(), Is.EqualTo(0)); } + // A pre-id record this cache reads only through the catalog's schema name is read-only, so the rewrite that would persist its + // minted ids is refused typed, naming it, and the store is left byte for byte. + [Test] + public void MintElementIdsIsRefusedByAReadOnlyRecordWithMintedIds() + { + const string otherRuntimeId = "other.runtime.id_deck"; + var path = PathOf("mint-read-only.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = otherRuntimeId; + entry.ContentHash = otherRuntimeId; + entry.CompatibleSchemaIds = new[] { otherRuntimeId }; + snapshot.Records.Single().SchemaId = otherRuntimeId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = File.ReadAllBytes(path); + + using (var cache = Open(path)) + { + Assert.That(AllIds(cache.Get(new CultRecordKey("a"))!), Has.All.Not.Empty, "the load minted ids in memory"); + var refusal = Assert.Throws(() => cache.MintElementIds())!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo(("a", otherRuntimeId))); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + } + [Test] public void TheByteCostOfAnIdIsMeasured() { diff --git a/tests/GameCult.Caching.Tests/ForeignRecordTests.cs b/tests/GameCult.Caching.Tests/ForeignRecordTests.cs index b469f6e5..91e10ed7 100644 --- a/tests/GameCult.Caching.Tests/ForeignRecordTests.cs +++ b/tests/GameCult.Caching.Tests/ForeignRecordTests.cs @@ -13,6 +13,10 @@ namespace GameCult.Caching.Tests public class ForeignRecordTests { private static readonly Type Deck = Emit("ForeignSuiteDeck", "tests.foreign_deck", "tests.foreign_deck.v1", new[] { new Field("Name", typeof(string), 0, IsName: true) }); + // The deck's schema declaring another runtime's id compatible (OtherRuntimeId below): one under the deck's own version, and a + // later version that a registry holding the deck can register beside it. + private static readonly Type ClaimingDeck = Emit("ForeignSuiteClaimingDeck", "tests.foreign_deck", "tests.foreign_deck.v1", new[] { new Field("Name", typeof(string), 0, IsName: true) }, new[] { "other.runtime.deck" }); + private static readonly Type LateClaimingDeck = Emit("ForeignSuiteLateClaimingDeck", "tests.foreign_deck", "tests.foreign_deck.v2", new[] { new Field("Name", typeof(string), 0, IsName: true) }, new[] { "other.runtime.deck" }); private static readonly Type Widget = Emit("ForeignSuiteWidget", "tests.foreign_widget", "tests.foreign_widget.v1", new[] { new Field("Label", typeof(string), 0, IsName: true) }); // A build that has both types, and one that has only the deck: to it the widget is foreign. @@ -23,6 +27,7 @@ namespace GameCult.Caching.Tests private static readonly CultRecordKey D = new("d"); private static readonly CultRecordKey E = new("e"); private static readonly CultRecordKey W = new("w"); + private static readonly CultRecordKey V = new("v"); private const string Canary = "CANARY-7f3c-payload-value"; public enum Write @@ -570,17 +575,23 @@ namespace GameCult.Caching.Tests // A deck another runtime wrote under its own id for the deck's schema. This build reads it through the catalog's schema name, // but does not own the id. - private string OtherRuntimeStore(string name) + private string OtherRuntimeStore(string name, bool withVariant = false) { var path = PathOf(name); using (var cache = Open(path, DeckOnly, directory: false)) + { cache.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + if (withVariant) + cache.Commit(batch => batch.UpsertVariant(V, D, new[] { cache.Override(Deck, "Name", "v") })); + } + var snapshot = Read(path); var entry = snapshot.SchemaCatalog.Single(); entry.SchemaId = OtherRuntimeId; entry.ContentHash = OtherRuntimeId; entry.CompatibleSchemaIds = new[] { OtherRuntimeId }; - snapshot.Records.Single().SchemaId = OtherRuntimeId; + foreach (var record in snapshot.Records) + record.SchemaId = OtherRuntimeId; // An older entry that also lists the id, first in the catalog: the entry that owns the id is the one that publishes it. snapshot.SchemaCatalog = new[] { @@ -643,6 +654,8 @@ namespace GameCult.Caching.Tests Assert.That(Stored(path, D), Is.EqualTo(before)); Assert.That(CatalogEntryBytes(path, OtherRuntimeId), Is.EqualTo(entry)); + using var reopened = Open(path, DeckOnly, directory: false); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(E)!, "Name"), Is.EqualTo("e"), "the write beside the read-only record landed"); } // The entry that publishes a copied record is the one the store holds when the write lands, not the one the record loaded with. @@ -661,18 +674,116 @@ namespace GameCult.Caching.Tests Assert.That(CatalogEntryBytes(path, OtherRuntimeId), Is.EqualTo(current)); } - // A write of the record itself stores what the cache holds, under the type's own id. + // A record read through the catalog's schema name under an id this build neither owns nor declares is read-only: a write of + // it is refused typed before anything is staged, the store is left byte for byte, and the record is still served. [Test] - public void AWriteOfTheRecordItselfStoresItUnderTheTypesOwnId([Values(Write.Flush, Write.Commit)] Write write) + public void ARecordReadByNameIsReadOnly([Values(Write.Flush, Write.Commit)] Write write) { - var path = OtherRuntimeStore("rewritten.cc"); + var path = OtherRuntimeStore("read-only.cc"); + var before = Fingerprint(path); using (var cache = Open(path, DeckOnly, directory: false)) - Land(cache, write, D, DeckOf("changed")); + { + var refusal = Assert.Throws(() => Land(cache, write, D, DeckOf("changed")))!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((D.Value, OtherRuntimeId))); + Assert.That(refusal.SchemaNames, Is.EqualTo(new[] { "tests.foreign_deck" }), "it names the type that reads it"); + Assert.That(cache.IsDirty, Is.False); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d")); + cache.FlushAsync().GetAwaiter().GetResult(); + } - Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(DeckOnly.GetRequired(Deck).SchemaId)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + public enum ReadOnlyWrite + { + Upsert, + UpsertVariantAtItsKey, + Flatten, + Remove, + CommitUpsert, + CommitRemove + } + + // Every cache write path refuses a staged write or removal of a read-only record before staging: the typed conflict, nothing + // dirty, the store byte for byte after a later flush, and the record still served. + [Test] + public void EveryWritePathRefusesAReadOnlyRecord([Values] ReadOnlyWrite path) + { + var file = OtherRuntimeStore("every-path-" + path + ".cc", withVariant: true); + var before = Fingerprint(file); + + using (var cache = Open(file, DeckOnly, directory: false)) + { + var key = path is ReadOnlyWrite.UpsertVariantAtItsKey or ReadOnlyWrite.Flatten ? V : D; + var refusal = Assert.Throws(() => + { + switch (path) + { + case ReadOnlyWrite.Upsert: + cache.UpsertAsync(Deck, DeckOf("changed"), D).GetAwaiter().GetResult(); + break; + case ReadOnlyWrite.UpsertVariantAtItsKey: + cache.UpsertVariantAsync(V, D, new[] { cache.Override(Deck, "Name", "changed") }).GetAwaiter().GetResult(); + break; + case ReadOnlyWrite.Flatten: + cache.FlattenAsync(V).GetAwaiter().GetResult(); + break; + case ReadOnlyWrite.Remove: + cache.Remove(D); + break; + case ReadOnlyWrite.CommitUpsert: + cache.Commit(batch => batch.Upsert(Deck, DeckOf("changed"), D)); + break; + case ReadOnlyWrite.CommitRemove: + cache.Commit(batch => batch.Remove(D)); + break; + } + })!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, OtherRuntimeId))); + Assert.That(cache.IsDirty, Is.False, "nothing was staged"); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d")); + Assert.That(EmittedDocumentTypes.Read(cache.Get(V)!, "Name"), Is.EqualTo("v")); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(Fingerprint(file), Is.EqualTo(before)); + } + + // A type that declares the id compatible owns the record: its write lands under the type's own id and reopens. + [Test] + public void DeclaringTheIdClaimsTheRecord() + { + var path = OtherRuntimeStore("declared.cc"); + var claiming = CultDocumentRegistry.ForTypes(new[] { ClaimingDeck }); + + using (var cache = Open(path, claiming, directory: false)) + cache.Commit(batch => batch.Upsert(ClaimingDeck, New(ClaimingDeck, ("Name", "claimed")), D)); + + Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(claiming.GetRequired(ClaimingDeck).SchemaId)); + using var reopened = Open(path, claiming, directory: false); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(D)!, "Name"), Is.EqualTo("claimed")); + } + + // Whether a record is read-only is read live from the registry at the write, not fixed at load: a type registered after the + // load that declares the id claims the record. + [Test] + public void ATypeRegisteredAfterLoadThatDeclaresTheIdClaimsIt() + { + var path = OtherRuntimeStore("late.cc"); + var registry = CultDocumentRegistry.ForTypes(new[] { Deck }); + + using (var cache = Open(path, registry, directory: false)) + { + Assert.Throws(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf("refused"), D))); + registry.GetRequired(LateClaimingDeck); + cache.Commit(batch => batch.Upsert(Deck, DeckOf("claimed"), D)); + } + + Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(registry.GetRequired(Deck).SchemaId)); using var reopened = Open(path, DeckOnly, directory: false); - Assert.That(EmittedDocumentTypes.Read(reopened.Get(D)!, "Name"), Is.EqualTo("changed")); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(D)!, "Name"), Is.EqualTo("claimed")); } // A commit onto the file that involves a variant is judged on the set the file will hold. A foreign record is not decoded diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index 99304283..55f20067 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -904,6 +904,73 @@ namespace GameCult.Caching.Tests Assert.That(reused.Get(DriftVariant)!.Colour, Is.Empty, "the variant inherits its base's colour, not the old wing"); } + public enum UndeclaredWrite + { + UpsertBase, + UpsertVariant, + FlattenVariant, + RemoveBase, + RemoveVariant + } + + // Narrow reads Wide's records through the schema name and does not list Wide's id, so they are read-only to it: a staged write, + // flatten or removal of either is refused typed, and the store keeps them as Wide stored them, the dropped slot's override too. + [Test] + public void AnUndeclaredDroppedSlotRecordThatIsWrittenIsRefused([Values] UndeclaredWrite write) + { + var path = PathOf("drift-undeclared-" + write + ".cc"); + SeedDrift(path); + var before = File.ReadAllBytes(path); + + using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }))) + { + var key = write is UndeclaredWrite.UpsertBase or UndeclaredWrite.RemoveBase ? DriftBase : DriftVariant; + var refusal = Assert.Throws(() => + { + switch (write) + { + case UndeclaredWrite.UpsertBase: + narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "changed" }, DriftBase)); + break; + case UndeclaredWrite.UpsertVariant: + narrow.Commit(batch => batch.UpsertVariant(DriftVariant, DriftBase, new[] { narrow.Override(nameof(DriftNarrow.Name), "changed") })); + break; + case UndeclaredWrite.FlattenVariant: + narrow.FlattenAsync(DriftVariant).GetAwaiter().GetResult(); + break; + case UndeclaredWrite.RemoveBase: + narrow.Commit(batch => batch.Remove(DriftBase)); + break; + case UndeclaredWrite.RemoveVariant: + narrow.Commit(batch => batch.Remove(DriftVariant)); + break; + } + })!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, DriftWideId))); + narrow.FlushAllBackingStores(); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 })); + } + + // Only the read-only key is read-only: a variant of a read-only base, written at a new key, is a write of the variant alone and lands. + [Test] + public void AVariantOfAReadOnlyBaseLands() + { + var path = PathOf("drift-read-only-base.cc"); + SeedDrift(path); + var fresh = new CultRecordKey("fresh-variant"); + var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }); + + using (var narrow = Open(path, registry: narrowRegistry)) + narrow.Commit(batch => batch.UpsertVariant(fresh, DriftBase, new[] { narrow.Override(nameof(DriftNarrow.Name), "fresh") })); + + using var reopened = Open(path, registry: narrowRegistry); + Assert.That(reopened.Get(fresh)!.Name, Is.EqualTo("fresh")); + Assert.That(ReadStore(path).Records.Single(record => record.Key == DriftBase.Value).SchemaId, Is.EqualTo(DriftWideId)); + } + [Test] public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten() {