Test: pin the Stryker survivors in the foreign-record and lay-back cut

- The lay-back fixture gains an older entry listing the id ahead of the
  one that owns it, and a test where another writer re-describes the id
  after the load: a whole view (flush and commit) lays back the entry it
  loaded. Kills the survivors in Publisher's own-id preference, the
  laid-back entry's precedence in the written catalog, and the commit's
  choice of that catalog.
- A store whose file is gone lists no foreign records.
- The registry's public resolution still refuses an unclaimed id, naming
  the schema and the id (was NoCoverage).
- A directory load retried after a writer's commit lists each foreign
  record once.
- Deleted LaidBack.ExceptWith(replaced): equivalent, since an upserted key's
  new entry already carries what the cache would be told.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 22:59:52 +02:00
commit 904c00ac81
2 changed files with 81 additions and 3 deletions

View file

@ -3791,10 +3791,7 @@ namespace GameCult.Caching
foreach (var entry in request.Upserts)
Entries[entry.Key.Value] = entry;
if (view != null)
{
view.LaidBack.ExceptWith(replaced);
WroteWholeView(records.Values, view.LaidBack);
}
MarkFlushSucceeded();
return CultCommitOutcome.Committed;
}

View file

@ -239,6 +239,35 @@ namespace GameCult.Caching.Tests
Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "d", "w" }));
}
// A directory load without a lease that a writer's commit forces to retry lists each foreign record once, from the manifest
// the retry read.
[Test]
public void ARetriedDirectoryLoadListsEachForeignRecordOnce()
{
var path = Seeded("retried.cc", directory: true);
var store = new DirectoryMessagePackBackingStore(path);
using var cache = new CultCache(DeckOnly);
cache.AddBackingStore(store);
File.Delete(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock"));
var commits = 0;
// The writer is another cache, so it commits from another thread; it commits once, under the first unleased read.
store.UnleasedManifestRead = () =>
{
if (commits++ > 0)
return;
System.Threading.Tasks.Task.Run(() =>
{
using var writer = Open(path, Full, directory: true);
writer.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E));
}).GetAwaiter().GetResult();
};
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Assert.That(cache.Get(E), Is.Not.Null, "the load retried and read the writer's generation");
Assert.That(store.ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
}
// The file decides what is carried, not what this cache loaded: a foreign record another writer added since is kept too.
[TestCase(false)]
[TestCase(true)]
@ -295,10 +324,62 @@ namespace GameCult.Caching.Tests
entry.ContentHash = OtherRuntimeId;
entry.CompatibleSchemaIds = new[] { OtherRuntimeId };
snapshot.Records.Single().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[]
{
new CultSchemaCatalogEntry
{
SchemaId = "decoy.older", SchemaName = "tests.decoy", SchemaVersion = "tests.decoy.v1", ContentHash = "decoy.older",
CompatibleSchemaIds = new[] { "decoy.older", OtherRuntimeId }
},
entry
};
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
return path;
}
// The record and the entry that described it when it loaded travel together: another writer re-describing the id since
// does not change what a whole view lays back.
[Test]
public void AWholeViewLaysBackTheCatalogEntryItLoadedWhateverTheFileSaysNow([Values(Write.Flush, Write.Commit)] Write write)
{
var path = OtherRuntimeStore("redescribed.cc");
var loaded = CatalogEntryBytes(path, OtherRuntimeId);
using var cache = Open(path, DeckOnly, directory: false);
var snapshot = Read(path);
snapshot.SchemaCatalog.Single(entry => entry.SchemaId == OtherRuntimeId).SchemaVersion = "tests.foreign_deck.redescribed";
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
Land(cache, write, E, DeckOf("e"));
Assert.That(CatalogEntryBytes(path, OtherRuntimeId), Is.EqualTo(loaded));
}
// A store whose file is gone holds nothing, foreign records included.
[Test]
public void AStoreWhoseFileIsGoneListsNoForeignRecords()
{
var path = Seeded("gone.cc", directory: false);
using var cache = Open(path, DeckOnly, directory: false);
File.Delete(path);
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Assert.That(StoreOf(cache).ForeignRecords, Is.Empty);
}
// The registry's public resolution still refuses an id no registered type claims, naming the schema and the id: only a store
// carries such a record.
[Test]
public void ResolvingAnUnclaimedIdThroughTheRegistryRefusesByName()
{
var entry = Full.GetRequired(Widget).ToCatalogEntry();
var refusal = Assert.Throws<InvalidOperationException>(() => DeckOnly.ResolvePersistedSchema(WidgetId, new[] { entry }))!;
Assert.That(refusal.Message, Does.Contain("tests.foreign_widget").And.Contain(WidgetId));
}
private static byte[] CatalogEntryBytes(string path, string schemaId) =>
CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
{