Tests: a record a type reads but no type declares is read-only on every cache write path

The relabel tests become refusals: ARecordReadByNameIsReadOnly (name branch)
and AnOlderVersionCannotWriteANewerRecordItReadsThroughTheNewerEntry (a
persisted entry's list). New: every write path refuses before staging,
declaring the id claims the record, a type registered after load claims it,
MintElementIds is refused by a read-only record, the undeclared dropped-slot
case, and a variant of a read-only base lands. The copy test also checks the
write beside the read-only record landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 12:34:58 +02:00
commit f17ef3d825
4 changed files with 241 additions and 35 deletions

View file

@ -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<CultSchemaConflictException>(() =>
{
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.

View file

@ -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<IdDeck>(new CultRecordKey("a"))!), Has.All.Not.Empty, "the load minted ids in memory");
var refusal = Assert.Throws<CultSchemaConflictException>(() => 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()
{

View file

@ -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<CultSchemaConflictException>(() => 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<CultSchemaConflictException>(() =>
{
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<CultSchemaConflictException>(() => 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

View file

@ -904,6 +904,73 @@ namespace GameCult.Caching.Tests
Assert.That(reused.Get<DriftReused>(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<CultSchemaConflictException>(() =>
{
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<DriftNarrow>(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<DriftNarrow>(nameof(DriftNarrow.Name), "fresh") }));
using var reopened = Open(path, registry: narrowRegistry);
Assert.That(reopened.Get<DriftNarrow>(fresh)!.Name, Is.EqualTo("fresh"));
Assert.That(ReadStore(path).Records.Single(record => record.Key == DriftBase.Value).SchemaId, Is.EqualTo(DriftWideId));
}
[Test]
public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten()
{