CultLib/tests/GameCult.Caching.Tests/ForeignRecordTests.cs
Meta Vens da951adce0 Tests: fixtures that were read-only because their type read the record whole now lose a slot; restamp tests fixed
The ForeignRecordTests read-only fixtures (another runtime's deck, the moving deck) had the same shape as the deck, which the
type now reads whole and owns; each gets a slot the deck lacks, so it is read only by dropping it. RestampOnWriteTests and the
successful-churn gate test are corrected from the first run. Not run at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:46:27 +01:00

1631 lines
85 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Linq;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
using static GameCult.Caching.Tests.EmittedDocumentTypes;
namespace GameCult.Caching.Tests
{
// A record under a schema id no registered type owns or lists as compatible is foreign. CultCache never destroys or relabels
// it: both store kinds carry it byte for byte, list it as foreign, and refuse any write that would replace or remove it.
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 second type a cache registers, so that a second backing store has a home of its own.
private static readonly Type Gizmo = Emit("ForeignSuiteGizmo", "tests.foreign_gizmo", "tests.foreign_gizmo.v1", new[] { new Field("Tag", typeof(string), 0, IsName: true) });
// A build that has both types, and one that has only the deck: to it the widget is foreign.
private static readonly CultDocumentRegistry Full = CultDocumentRegistry.ForTypes(new[] { Deck, Widget });
private static readonly CultDocumentRegistry DeckOnly = CultDocumentRegistry.ForTypes(new[] { Deck });
private static readonly string WidgetId = Full.GetRequired(Widget).SchemaId;
// Another writer's deck (an older or newer build, another runtime) under an id the deck does not declare. It declares the
// deck's own id compatible, so it may rewrite the deck's records; the deck still reads them through that listing, but only by
// dropping the Note slot it lacks, so it does not own them and holds them read-only.
private static readonly Type MovingDeck = Emit("ForeignSuiteMovingDeck", "tests.foreign_deck", "tests.foreign_deck.v3", new[] { new Field("Name", typeof(string), 0, IsName: true), new Field("Note", typeof(string), 1) }, new[] { DeckOnly.GetRequired(Deck).SchemaId });
private static readonly CultDocumentRegistry Moving = CultDocumentRegistry.ForTypes(new[] { MovingDeck });
private static readonly string MovingId = Moving.GetRequired(MovingDeck).SchemaId;
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
{
Flush,
Commit,
ConditionalCommit
}
private string _directory = "";
[SetUp]
public void SetUp()
{
_directory = Path.Combine(Path.GetTempPath(), "cultlib-foreign-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_directory);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, true);
}
private string PathOf(string name) => Path.Combine(_directory, name);
private static CultCache Open(string path, CultDocumentRegistry registry, bool directory) =>
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = directory });
private static object DeckOf(string name) => New(Deck, ("Name", name));
private static object WidgetOf(string label) => New(Widget, ("Label", label));
private static CultPersistedStoreSnapshot Read(string path) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
// Every file of the store, by name, with its bytes: a refused write leaves all of them as they were.
private static string[] Fingerprint(string path)
{
var files = new[] { path }.Concat(Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path))
? Directory.GetFiles(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), "*.msgpack")
: Array.Empty<string>())
.OrderBy(file => file, StringComparer.Ordinal);
return files.Select(file => Path.GetFileName(file) + ":" + Convert.ToBase64String(File.ReadAllBytes(file))).ToArray();
}
// What the store holds for one record as the file (single-file) or manifest (directory: the page hash) names it.
private static string Stored(string path, CultRecordKey key)
{
var record = Read(path).Records.Single(candidate => candidate.Key == key.Value);
return $"{record.SchemaId}|{record.StoredAt}|{Convert.ToBase64String(record.Payload)}|{record.Variant == null}";
}
// A store holding a deck d and a widget w, written by the build that has both types.
private string Seeded(string name, bool directory)
{
var path = PathOf(name);
using var cache = Open(path, Full, directory);
cache.Commit(batch =>
{
batch.Upsert(Deck, DeckOf("d"), D);
batch.Upsert(Widget, WidgetOf("w"), W);
});
return path;
}
private static CacheBackingStore StoreOf(CultCache cache) => cache.BackingStores.Single();
private static void Land(CultCache cache, Write write, CultRecordKey key, object document)
{
switch (write)
{
case Write.Flush:
cache.UpsertAsync(document.GetType(), document, key).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
break;
case Write.Commit:
Assert.That(cache.Commit(batch => batch.Upsert(document.GetType(), document, key)), Is.True);
break;
case Write.ConditionalCommit:
Assert.That(cache.Commit(batch =>
{
batch.Expect(D, cache.Get(D));
batch.Upsert(document.GetType(), document, key);
}), Is.True);
break;
}
}
private static void Refused(CultCache cache, Write write, CultRecordKey key, object document, CultRecordKey foreignKey)
{
var refusal = Assert.Throws<CultSchemaConflictException>(() => Land(cache, write, key, document))!;
Assert.Multiple(() =>
{
Assert.That(refusal.RecordKey, Is.EqualTo(foreignKey.Value));
Assert.That(refusal.SchemaId, Is.EqualTo(WidgetId));
Assert.That(refusal.SchemaNames, Is.EqualTo(new[] { "tests.foreign_widget" }));
Assert.That(refusal.Message, Does.Contain(foreignKey.Value).And.Contain(WidgetId));
Assert.That(refusal.Message, Does.Not.Contain(Canary), "a refusal never echoes the document it refused");
});
}
[TestCase(false)]
[TestCase(true)]
public void AForeignRecordLoadsAsForeignAndTheCacheDoesNotHoldIt(bool directory)
{
var path = Seeded("seeded.cc", directory);
var storedAt = Read(path).Records.Single(record => record.Key == W.Value).StoredAt;
using var cache = Open(path, DeckOnly, directory);
var foreign = StoreOf(cache).ForeignRecords.Single();
Assert.Multiple(() =>
{
Assert.That(foreign.Key, Is.EqualTo(W.Value));
Assert.That(foreign.SchemaId, Is.EqualTo(WidgetId));
Assert.That(foreign.SchemaName, Is.EqualTo("tests.foreign_widget"));
Assert.That(foreign.StoredAt, Is.EqualTo(storedAt));
Assert.That(cache.Get(W), Is.Null, "the cache never holds a foreign record");
Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d"), "the records it claims load as usual");
});
}
// Every write path lays the foreign record back as the file holds it: the single-file store's whole view (a flush and an
// unconditional commit), its commit onto the file, and the directory store's generation.
[Test]
public void AWriteOfAnotherRecordKeepsTheForeignRecordByteForByte(
[Values(false, true)] bool directory,
[Values] Write write)
{
var path = Seeded("kept.cc", directory);
var before = Stored(path, W);
using (var cache = Open(path, DeckOnly, directory))
Land(cache, write, E, DeckOf("e"));
Assert.That(Stored(path, W), Is.EqualTo(before));
Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "w" }));
using var full = Open(path, Full, directory);
Assert.That(EmittedDocumentTypes.Read(full.Get(W)!, "Label"), Is.EqualTo("w"), "the build that has the type still reads it");
Assert.That(StoreOf(full).ForeignRecords, Is.Empty);
}
[Test]
public void AWriteThatWouldReplaceAForeignRecordIsRefusedAndNothingIsWritten(
[Values(false, true)] bool directory,
[Values] Write write)
{
var path = Seeded("replace.cc", directory);
var before = Fingerprint(path);
using var cache = Open(path, DeckOnly, directory);
Refused(cache, write, W, DeckOf(Canary), W);
Assert.That(Fingerprint(path), Is.EqualTo(before));
Assert.That(cache.Get(W), Is.Null);
}
// Another writer replaced a record this cache holds with a record of a type it does not have. A write that would lay this
// cache's record over it is refused; the file keeps the other writer's record. The refusal reloads it as a pull would: the
// store lists it foreign, so the next write of it is refused before staging.
[Test]
public void AWriteOverARecordAnotherWriterMadeForeignIsRefused([Values] bool directory, [Values(Write.Flush, Write.Commit)] Write write)
{
var path = Seeded("replaced.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var before = Fingerprint(path);
var refusal = Assert.Throws<CultSchemaConflictException>(() => Land(cache, write, D, DeckOf(Canary)))!;
Assert.That(refusal.RecordKey, Is.EqualTo(D.Value));
Assert.That(refusal.Message, Does.Not.Contain(Canary));
Assert.That(Fingerprint(path), Is.EqualTo(before));
Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "d", "w" }));
Assert.Throws<CultSchemaConflictException>(() => cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult());
Assert.That(cache.IsDirty, Is.False);
}
// A refusal does not poison the store: what the cache held for the refused record goes with the refusal, so a later write
// to another record lands and the file keeps the other writer's record.
[Test]
public void AfterARefusedWriteAWriteToAnotherRecordLands(
[Values(false, true)] bool directory,
[Values(Write.Flush, Write.Commit)] Write refused)
{
var path = Seeded("unstuck.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
Assert.Throws<CultSchemaConflictException>(() => Land(cache, refused, D, DeckOf(Canary)));
Land(cache, Write.Commit, E, DeckOf("e"));
Assert.That(Stored(path, D), Is.EqualTo(widget), "the other writer's record is as it was");
Assert.That(EmittedDocumentTypes.Read(cache.Get(E)!, "Name"), Is.EqualTo("e"));
Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e"));
}
// The cache and the store agree on what a refusal left: the refused record is not served, not after a later write, not after
// a pull, and not by a fresh open. What the cache served before the refusal was never what the file holds.
[Test]
public void ARefusedRecordIsNotServedAfterTheRefusalAfterALaterWriteOrAfterAPull(
[Values(false, true)] bool directory,
[Values(Write.Flush, Write.Commit)] Write refused)
{
var path = Seeded("not-served.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var refusal = Assert.Throws<CultSchemaConflictException>(() => Land(cache, refused, D, DeckOf(Canary)))!;
Assert.That(refusal.Message, Does.StartWith("Record 'd' (schema id '" + WidgetId + "'"), "one refused key reads as one record");
var afterRefusal = cache.Get(D);
Land(cache, Write.Commit, E, DeckOf("e"));
var afterLaterWrite = cache.Get(D);
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
var afterPull = cache.Get(D);
using var fresh = Open(path, DeckOnly, directory);
Assert.Multiple(() =>
{
Assert.That(afterRefusal, Is.Null, "after the refusal");
Assert.That(afterLaterWrite, Is.Null, "after a later write");
Assert.That(afterPull, Is.Null, "after a pull");
Assert.That(fresh.Get(D), Is.Null, "a fresh open");
Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(StoreOf(fresh).ForeignRecords.Select(record => record.Key)));
});
}
// A refusal names every key it refused, and every one of them leaves the cache; a record the write did not touch stays.
[Test]
public void ARefusalOfSeveralRecordsNamesEachAndTheCacheDropsEach(
[Values(false, true)] bool directory,
[Values(Write.Flush, Write.Commit)] Write refused)
{
var path = PathOf("several.cc");
var d2 = new CultRecordKey("d2");
using (var seed = Open(path, Full, directory))
seed.Commit(batch =>
{
batch.Upsert(Deck, DeckOf("d"), D);
batch.Upsert(Deck, DeckOf("d2"), d2);
batch.Upsert(Deck, DeckOf("e"), E);
});
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch =>
{
batch.Upsert(Widget, WidgetOf("w1"), D);
batch.Upsert(Widget, WidgetOf("w2"), d2);
});
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
{
if (refused == Write.Flush)
{
cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult();
cache.UpsertAsync(Deck, DeckOf(Canary), d2).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
else
{
cache.Commit(batch =>
{
batch.Upsert(Deck, DeckOf(Canary), D);
batch.Upsert(Deck, DeckOf(Canary), d2);
});
}
})!;
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Assert.Multiple(() =>
{
Assert.That(refusal.RecordKey, Is.EqualTo("d"), "the first refused key in key order");
Assert.That(refusal.Message, Does.Contain("Records 'd' (schema id '" + WidgetId + "'").And.Contain("'d2' (schema id '" + WidgetId + "'").And.Not.Contain(Canary));
Assert.That(cache.Get(D), Is.Null);
Assert.That(cache.Get(d2), Is.Null);
Assert.That(EmittedDocumentTypes.Read(cache.Get(E)!, "Name"), Is.EqualTo("e"), "a record the write did not touch stays");
});
}
// A write that never touches a record another writer made foreign is not refused for holding a stale copy of it: it lands
// the first time, in both store kinds, and the next pull drops the copy.
[Test]
public void AWriteThatNeverTouchedARecordAnotherWriterMadeForeignLandsAtOnce(
[Values(false, true)] bool directory,
[Values(Write.Flush, Write.Commit)] Write write)
{
var path = Seeded("untouched.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
Land(cache, write, E, DeckOf("e"));
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Assert.Multiple(() =>
{
Assert.That(Stored(path, D), Is.EqualTo(widget), "the other writer's record is as it was");
Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e"));
Assert.That(cache.Get(D), Is.Null, "the pull drops the stale copy");
});
}
// The write carried the file's record for d; it did not store this cache's copy, so the copy's stamp is still what the
// cache read, and a condition on it still fails against the other writer's record.
[Test]
public void ACarriedForeignRecordDoesNotRestampTheStaleCopyTheCacheHolds(
[Values(false, true)] bool directory,
[Values(Write.Flush, Write.Commit)] Write write)
{
var path = Seeded("restamp.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
Land(cache, write, E, DeckOf("e"));
Assert.That(cache.Commit(batch =>
{
batch.Expect(D, cache.Get(D));
batch.Upsert(Deck, DeckOf("e2"), E);
}), Is.False, "the cache never read the other writer's record");
}
// The same when the stale copy was only held, never written: a flush after the other writer's record landed refuses once.
[Test]
public void AFlushRefusedForAStaleCopyOfAForeignRecordRefusesOnce([Values(false, true)] bool directory)
{
var path = Seeded("stale.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
Assert.Throws<CultSchemaConflictException>(() => Land(cache, Write.Commit, D, DeckOf(Canary)));
Land(cache, Write.Flush, E, DeckOf("e"));
Land(cache, Write.Flush, E, DeckOf("e again"));
Assert.That(Stored(path, D), Is.EqualTo(widget));
}
// A key staged and not yet written is refused by any write that carries it, whatever key that write names: an unconditional
// commit writes what a flush would.
[Test]
public void AStagedWriteIsRefusedByACommitOfAnotherRecord([Values(false, true)] bool directory)
{
var path = Seeded("staged-commit.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult();
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E)))!;
Assert.That(refusal.RecordKey, Is.EqualTo(D.Value));
Assert.That(Stored(path, D), Is.EqualTo(widget));
}
// What a write landed is no longer staged: a record that becomes foreign afterwards is the other writer's, and writing
// another record is not refused for it.
[Test]
public void AWrittenRecordIsNoLongerStagedSoALaterForeignRecordDoesNotRefuseOtherWrites(
[Values(false, true)] bool directory,
[Values(Write.Flush, Write.Commit)] Write landed)
{
var path = Seeded("landed.cc", directory);
using var cache = Open(path, DeckOnly, directory);
Land(cache, landed, D, DeckOf("d again"));
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
Land(cache, Write.Commit, E, DeckOf("e"));
Assert.That(Stored(path, D), Is.EqualTo(widget));
Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e"));
}
// A commit writes the whole view, so what it landed that was staged is no longer staged either.
[Test]
public void AStagedWriteLandedByACommitIsNoLongerStaged([Values(false, true)] bool directory)
{
var path = Seeded("staged-landed.cc", directory);
using var cache = Open(path, DeckOnly, directory);
cache.UpsertAsync(Deck, DeckOf("d staged"), D).GetAwaiter().GetResult();
Land(cache, Write.Commit, E, DeckOf("e"));
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
Land(cache, Write.Commit, new CultRecordKey("e2"), DeckOf("e2"));
Assert.That(Stored(path, D), Is.EqualTo(widget));
Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e2"));
}
// A staged removal is a write of its own: a refusal takes it too.
[TestCase(false)]
[TestCase(true)]
public void AStagedRemovalOfARecordAnotherWriterMadeForeignIsRefusedOnceAndThenGone(bool directory)
{
var path = Seeded("staged-removal.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var widget = Stored(path, D);
cache.Remove(D);
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.FlushAsync().GetAwaiter().GetResult())!;
Land(cache, Write.Commit, E, DeckOf("e"));
Assert.That(refusal.RecordKey, Is.EqualTo(D.Value));
Assert.That(Stored(path, D), Is.EqualTo(widget));
}
// What a refusal leaves staged is exactly what it did not refuse: the other record's write still lands on the next flush,
// and a store with nothing left staged is clean.
[TestCase(false)]
[TestCase(true)]
public void ARefusalLeavesStagedWhatItDidNotRefuse(bool directory)
{
var path = Seeded("pending.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult();
Assert.Throws<CultSchemaConflictException>(() => cache.FlushAsync().GetAwaiter().GetResult());
Assert.That(cache.IsDirty, Is.False, "nothing is left staged");
cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E));
cache.UpsertAsync(Deck, DeckOf(Canary), E).GetAwaiter().GetResult();
cache.UpsertAsync(Deck, DeckOf("v"), V).GetAwaiter().GetResult();
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), E));
Assert.Throws<CultSchemaConflictException>(() => cache.FlushAsync().GetAwaiter().GetResult());
Assert.That(cache.IsDirty, Is.True, "v is still staged");
cache.FlushAsync().GetAwaiter().GetResult();
Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("v"));
Assert.That(cache.IsDirty, Is.False);
}
[TestCase(false)]
[TestCase(true)]
public void RemovingARecordAnotherWriterMadeForeignIsRefused(bool directory)
{
var path = Seeded("removed.cc", directory);
using var cache = Open(path, DeckOnly, directory);
cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E));
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
var before = Fingerprint(path);
// A condition keeps the single-file commit on the file as it is, so only the removal reaches d.
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch =>
{
batch.Expect(E, cache.Get(E));
batch.Remove(D);
}))!;
Assert.That(refusal.RecordKey, Is.EqualTo(D.Value));
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
[TestCase(false)]
[TestCase(true)]
public void ARecordAnotherWriterMadeForeignLeavesTheCacheAtThePull(bool directory)
{
var path = Seeded("pulled.cc", directory);
using var cache = Open(path, DeckOnly, directory);
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), D));
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Assert.That(cache.Get(D), Is.Null);
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)]
public void AForeignRecordAddedAfterTheLoadIsKept(bool directory)
{
var path = Seeded("added.cc", directory);
using var cache = Open(path, DeckOnly, directory);
var z = new CultRecordKey("z");
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("z"), z));
var before = Stored(path, z);
Land(cache, Write.Flush, E, DeckOf("e"));
Assert.That(Stored(path, z), Is.EqualTo(before));
}
// A writer cannot see whether a foreign record holds element ids, so a store marked as holding them stays marked while it
// carries one: a write that copies any record forward keeps the mark.
[Test]
public void AMarkedHeaderStaysMarkedWhileTheStoreCarriesAForeignRecord([Values(Write.Flush, Write.Commit)] Write write)
{
var path = Seeded("marked.cc", directory: false);
var snapshot = Read(path);
snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV3;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
using (var cache = Open(path, DeckOnly, directory: false))
Land(cache, write, E, DeckOf("e"));
Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3));
}
private const string OtherRuntimeId = "other.runtime.deck";
// A deck another runtime wrote under its own id for the deck's schema, with a slot this build lacks. This build reads it
// through the catalog's schema name, but only by dropping that slot, so it does not own the id.
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 };
entry.Members = entry.Members.Append(new CultSchemaMemberCatalogEntry { Slot = 99, MemberName = "Dropped", TypeName = "System.String" }).ToArray();
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[]
{
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;
}
// 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
{
SchemaCatalog = Read(path).SchemaCatalog.Where(entry => entry.SchemaId == schemaId).ToArray()
});
// A write of another record copies a record under an id this build does not own as the store holds it: its id, storedAt,
// bytes and the catalog entry that publishes its id.
[Test]
public void AWriteOfAnotherRecordCopiesARecordUnderAnIdThisBuildDoesNotOwn([Values] Write write)
{
var path = OtherRuntimeStore("other-runtime.cc");
var before = Stored(path, D);
var entry = CatalogEntryBytes(path, OtherRuntimeId);
using (var cache = Open(path, DeckOnly, directory: false))
{
Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d"), "this build reads it");
Land(cache, write, E, DeckOf("e"));
}
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.
[Test]
public void ACopiedRecordIsPublishedByTheCatalogEntryTheStoreHoldsNow([Values(Write.Flush, Write.Commit)] Write write)
{
var path = OtherRuntimeStore("redescribed.cc");
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));
var current = CatalogEntryBytes(path, OtherRuntimeId);
Land(cache, write, E, DeckOf("e"));
Assert.That(CatalogEntryBytes(path, OtherRuntimeId), Is.EqualTo(current));
}
// A write over a foreign record is refused at the write itself, in both store kinds, not first at the flush: nothing is staged
// and the cache never holds the record.
[Test]
public void AWriteOverAForeignRecordIsRefusedBeforeStaging([Values] bool directory)
{
var path = Seeded(directory ? "staged-dir.cc" : "staged-file.cc", directory);
var before = Fingerprint(path);
using (var cache = Open(path, DeckOnly, directory))
{
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.UpsertAsync(Deck, DeckOf(Canary), W).GetAwaiter().GetResult())!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(refusal.Message, Does.Not.Contain(Canary));
Assert.That(cache.IsDirty, Is.False, "nothing was staged");
Assert.That(cache.Get(W), Is.Null);
cache.FlushAsync().GetAwaiter().GetResult();
}
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
// 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 ARecordReadByNameIsReadOnly([Values(Write.Flush, Write.Commit)] Write write)
{
var path = OtherRuntimeStore("read-only.cc");
var before = Fingerprint(path);
using (var cache = Open(path, DeckOnly, directory: false))
{
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(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 makes the record writable, and the write stores it under the writing type's id, not the declarer's.
[Test]
public void ATypeRegisteredAfterLoadThatDeclaresTheIdMakesItWritable()
{
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("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
// for that judgement, so it neither fails the commit nor joins the set.
[Test]
public void ACommitInvolvingAVariantIsNotRefusedByAForeignRecord()
{
var path = Seeded("variant.cc", directory: false);
var before = Stored(path, W);
using var cache = Open(path, DeckOnly, directory: false);
var v = new CultRecordKey("v");
cache.UpsertVariantAsync(v, D, new[] { cache.Override(Deck, "Name", "v") }).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
Land(cache, Write.ConditionalCommit, E, DeckOf("e"));
Assert.That(Stored(path, W), Is.EqualTo(before));
Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "v", "w" }));
}
// A variant's base that another writer replaced with a foreign record is gone from the set the file will hold, so a commit
// onto that file is refused as it would be if the base had been removed.
[Test]
public void ACommitOntoAFileWhereAForeignRecordReplacedAVariantsBaseIsRefused()
{
var path = Seeded("base.cc", directory: false);
using var cache = Open(path, DeckOnly, directory: false);
var v = new CultRecordKey("v");
cache.UpsertVariantAsync(v, D, new[] { cache.Override(Deck, "Name", "v") }).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
var snapshot = Read(path);
var widget = snapshot.Records.Single(record => record.Key == W.Value);
snapshot.Records = snapshot.Records
.Select(record => record.Key == D.Value
? new CultPersistedRecord { Key = D.Value, SchemaId = widget.SchemaId, StoredAt = widget.StoredAt, Payload = widget.Payload }
: record)
.ToArray();
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var before = Fingerprint(path);
var refusal = Assert.Throws(Is.InstanceOf<InvalidOperationException>(), () => cache.Commit(batch =>
{
batch.Expect(E, null);
batch.Upsert(Deck, DeckOf("e"), E);
}))!;
Assert.That(refusal.Message, Does.Contain(v.Value).And.Contain(D.Value));
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
// The other writer rewrites a record under its own id, which this build reads but does not declare.
private static void Move(string path, CultRecordKey key, string name, bool directory)
{
using var other = Open(path, Moving, directory);
Assert.That(other.Commit(batch => batch.Upsert(MovingDeck, New(MovingDeck, ("Name", name)), key)), Is.True);
}
private static void RefusedOnDisk(CultSchemaConflictException refusal, CultRecordKey key)
{
Assert.Multiple(() =>
{
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, MovingId)));
Assert.That(refusal.SchemaNames, Is.EqualTo(new[] { "tests.foreign_deck" }));
Assert.That(refusal.Message, Does.Not.Contain(Canary), "a refusal never echoes the document it refused");
});
}
// The staging half cannot see a record another writer moved to an undeclared id after this cache loaded it: the write is
// refused where it lands, under the store's lock, in both store kinds. Nothing is written, and the cache reloads the record as
// the store holds it, read-only, so the next write of it is refused before staging.
[Test]
public void AWriteOverARecordAnotherWriterMovedToAnUndeclaredIdIsRefusedAndReloadedReadOnly(
[Values] bool directory,
[Values(Write.Flush, Write.Commit)] Write write)
{
var path = PathOf("moved.cc");
using var cache = Open(path, DeckOnly, directory);
cache.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
if (write == Write.Flush)
cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult();
Move(path, D, "moved", directory);
var before = Fingerprint(path);
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
{
if (write == Write.Flush)
cache.FlushAsync().GetAwaiter().GetResult();
else
cache.Commit(batch => batch.Upsert(Deck, DeckOf(Canary), D));
})!;
RefusedOnDisk(refusal, D);
Assert.That(Fingerprint(path), Is.EqualTo(before));
Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("moved"), "the cache serves what the store holds");
Assert.That(cache.IsDirty, Is.False);
RefusedOnDisk(Assert.Throws<CultSchemaConflictException>(() => cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult())!, D);
Assert.That(cache.IsDirty, Is.False, "the second write was refused at staging");
cache.FlushAsync().GetAwaiter().GetResult();
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
[Test]
public void AStagedRemovalOfARecordMovedToAnUndeclaredIdIsRefused([Values] bool directory)
{
var path = PathOf("moved-removal.cc");
using var cache = Open(path, DeckOnly, directory);
cache.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
Assert.That(cache.Remove(D), Is.True);
Move(path, D, "moved", directory);
var moved = Stored(path, D);
RefusedOnDisk(Assert.Throws<CultSchemaConflictException>(() => cache.FlushAsync().GetAwaiter().GetResult())!, D);
Assert.That(Stored(path, D), Is.EqualTo(moved));
Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("moved"));
Assert.That(cache.IsDirty, Is.False);
}
// A key this cache never held, which another writer created under an undeclared id after the load: the write is refused, and
// the cache then holds the record read-only.
[Test]
public void ARecordThatAppearedUnderAnUndeclaredIdAtAWrittenKeyIsRefusedAndHeld([Values] bool directory)
{
var path = PathOf("appeared.cc");
using var cache = Open(path, DeckOnly, directory);
cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E));
Move(path, D, "appeared", directory);
var before = Fingerprint(path);
cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult();
RefusedOnDisk(Assert.Throws<CultSchemaConflictException>(() => cache.FlushAsync().GetAwaiter().GetResult())!, D);
Assert.That(Fingerprint(path), Is.EqualTo(before));
Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("appeared"));
RefusedOnDisk(Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf(Canary), D)))!, D);
Assert.That(cache.IsDirty, Is.False);
}
// A refusal reads the record it refused: in a store holding a variant, where a write lands only onto the store as this cache
// last read it, the next write of another record lands at once instead of being refused for the record already refused.
[Test]
public void AfterAnUndeclaredRefusalTheNextWriteIntoAVariantStoreLands([Values(Write.Flush, Write.Commit)] Write write)
{
var path = PathOf("variant-store.cc");
using var cache = Open(path, DeckOnly, directory: false);
cache.Commit(batch =>
{
batch.Upsert(Deck, DeckOf("d"), D);
batch.Upsert(Deck, DeckOf("e"), E);
batch.UpsertVariant(V, E, new[] { cache.Override(Deck, "Name", "v") });
});
Move(path, D, "moved", directory: false);
RefusedOnDisk(Assert.Throws<CultSchemaConflictException>(() => Land(cache, write, D, DeckOf(Canary)))!, D);
Land(cache, write, new CultRecordKey("f"), DeckOf("f"));
Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "f", "v" }));
Assert.That(Read(path).Records.Single(record => record.Key == D.Value).SchemaId, Is.EqualTo(MovingId));
}
public enum DirectWrite
{
Push,
Delete,
CommitUpsert,
CommitDelete
}
// The store's own write API reaches no staging refusal: a read-only record written through it directly is refused where the
// write lands, in both store kinds, and the store keeps the other writer's record under its id.
[Test]
public void ADirectStoreWriteOfAReadOnlyRecordIsRefusedAndNeverRelabelsIt([Values] bool directory, [Values] DirectWrite write)
{
var path = PathOf("direct.cc");
Move(path, D, "theirs", directory);
var before = Fingerprint(path);
using var cache = Open(path, DeckOnly, directory);
var store = StoreOf(cache);
var mine = new CultStoredDocument(D, DateTimeOffset.UtcNow.ToString("O"), DeckOnly.GetRequired(Deck), DeckOf(Canary));
var held = cache.GetStored(D)!;
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
{
switch (write)
{
case DirectWrite.Push:
store.Push(mine);
store.PushAll();
break;
case DirectWrite.Delete:
store.Delete(held);
store.PushAll();
break;
case DirectWrite.CommitUpsert:
store.CommitBatch(new CultCommitRequest(new[] { mine }, Array.Empty<CultStoredDocument>(), Array.Empty<string>(), Array.Empty<(CultRecordKey, string?)>(), false), wait: true);
break;
case DirectWrite.CommitDelete:
store.CommitBatch(new CultCommitRequest(Array.Empty<CultStoredDocument>(), new[] { held }, Array.Empty<string>(), Array.Empty<(CultRecordKey, string?)>(), false), wait: true);
break;
}
})!;
RefusedOnDisk(refusal, D);
Assert.That(Fingerprint(path), Is.EqualTo(before));
Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(MovingId), "never relabelled");
Assert.That(store.IsDirty, Is.False);
Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("theirs"));
}
// A removal of a foreign record, which the cache does not hold, is refused typed where the store judges it under its lock: at
// the commit that carries it, or the flush that follows a Remove, which itself costs nothing and returns false. The caller is
// never told a write removed what the store still carries. The store is read at that judgement, so a foreign record another
// writer added after the load is refused too, and listed foreign from then on.
[Test]
public void RemovingAForeignRecordIsRefusedNotReportedAsDone([Values] bool directory, [Values] bool batch, [Values] bool appearedAfterLoad)
{
var path = PathOf(directory ? "remove-foreign-dir.cc" : "remove-foreign-file.cc");
using (var seed = Open(path, Full, directory))
{
seed.Commit(stage => stage.Upsert(Deck, DeckOf("d"), D));
if (!appearedAfterLoad)
seed.Commit(stage => stage.Upsert(Widget, WidgetOf("w"), W));
}
using (var cache = Open(path, DeckOnly, directory))
{
if (appearedAfterLoad)
{
using var other = Open(path, Full, directory);
other.Commit(stage => stage.Upsert(Widget, WidgetOf("w"), W));
}
var before = Fingerprint(path);
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
{
if (batch)
cache.Commit(stage => stage.Remove(W));
else
{
Assert.That(cache.Remove(W), Is.False, "a key the cache does not hold costs nothing at the call");
cache.FlushAsync().GetAwaiter().GetResult();
}
})!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(cache.IsDirty, Is.False);
Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
Assert.DoesNotThrow(() => cache.FlushAsync().GetAwaiter().GetResult(), "a refusal forgets the check it refused");
Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False, "a key nothing holds is still no removal");
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
}
// A write that removes a key the cache does not hold is judged under the store's lock, after the load: a foreign record another
// writer committed in between refuses the whole commit, typed, and nothing the commit would have written lands.
[Test]
public void AForeignRecordThatLandsBetweenTheLoadAndTheCommitRefusesTheCommitThatRemovesIt([Values] bool directory)
{
var path = PathOf("window.cc");
using (var seed = Open(path, Full, directory))
seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
using var a = Open(path, DeckOnly, directory);
using (var b = Open(path, Full, directory))
b.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W));
var before = Fingerprint(path);
var refusal = Assert.Throws<CultSchemaConflictException>(() => a.Commit(batch =>
{
batch.Upsert(Deck, DeckOf(Canary), D);
batch.Remove(W);
}))!;
Assert.Multiple(() =>
{
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(refusal.Message, Does.Not.Contain(Canary));
Assert.That(Fingerprint(path), Is.EqualTo(before), "nothing was written, d included");
Assert.That(a.IsDirty, Is.False);
});
}
// Removing a key nothing holds reads no store: the judgement is the flush's, so a store that cannot be read costs the removal
// nothing and the flush is where it is read.
[Test]
public void RemovingAnUnheldKeyReadsNothingUntilTheFlush([Values] bool directory)
{
var path = Seeded("unreadable.cc", directory);
using var cache = Open(path, DeckOnly, directory);
var original = File.ReadAllBytes(path);
File.WriteAllBytes(path, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });
try
{
Assert.DoesNotThrow(() => Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False));
Assert.Throws<CultStoreUnreadableException>(() => cache.FlushAsync().GetAwaiter().GetResult());
}
finally
{
File.WriteAllBytes(path, original);
}
}
// A checked key is judged and never removed: another writer's declared record at it stays where it is.
[Test]
public void ACheckedKeyIsNeverRemoved([Values] bool directory)
{
var path = PathOf("checked.cc");
using (var seed = Open(path, Full, directory))
seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
using var a = Open(path, DeckOnly, directory);
a.UpsertAsync(Deck, DeckOf("e"), E).GetAwaiter().GetResult();
Assert.That(a.Remove(W), Is.False);
using (var b = Open(path, Full, directory))
b.Commit(batch => batch.Upsert(Deck, DeckOf("theirs"), W));
var theirs = Stored(path, W);
Assert.DoesNotThrow(() => a.FlushAsync().GetAwaiter().GetResult());
Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "w" }), "the staged write landed beside the checked key");
Assert.That(Stored(path, W), Is.EqualTo(theirs), "the record at the checked key is still the other writer's bytes");
using (var c = Open(path, Full, directory))
c.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), W));
Assert.DoesNotThrow(() => a.FlushAsync().GetAwaiter().GetResult(), "a flush clears what it judged: the next one has nothing to judge");
}
// ExpectUnchanged compares the foreign records a store carries at the versions it read them: it holds right after the load,
// and fails, writing nothing, once another writer changed one.
[Test]
public void ExpectUnchangedHoldsOverAStoreCarryingAForeignRecord([Values] bool directory)
{
var path = Seeded("expect-unchanged.cc", directory);
using var a = Open(path, DeckOnly, directory);
Assert.That(StoreOf(a).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
Assert.That(a.Commit(batch =>
{
batch.ExpectUnchanged();
batch.Upsert(Deck, DeckOf("e"), E);
}), Is.True, "nothing changed since the load");
using (var b = Open(path, Full, directory))
b.Commit(batch => batch.Upsert(Widget, WidgetOf("changed"), W));
var before = Fingerprint(path);
Assert.That(a.Commit(batch =>
{
batch.ExpectUnchanged();
batch.Upsert(Deck, DeckOf("f"), new CultRecordKey("f"));
}), Is.False, "another writer changed the foreign record");
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
// A foreign key listed at the load that another writer then replaced with a record this cache reads but does not declare: the
// refused removal (judged at the flush) reloads it read-only and takes it off the foreign list.
[Test]
public void AListedForeignKeyBecomesReadOnlyWhenARemovalOfItIsRefused([Values] bool directory)
{
var path = Seeded("delist.cc", directory);
using var a = Open(path, DeckOnly, directory);
Assert.That(StoreOf(a).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
// The writer declares the widget's id, so it may replace the widget with a deck record under an id a reads but does not declare.
using (var other = Open(path, CultDocumentRegistry.ForTypes(new[] { MovingDeck, Widget }), directory))
Assert.That(other.Commit(batch => batch.Upsert(MovingDeck, New(MovingDeck, ("Name", "moved")), W)), Is.True);
Assert.That(a.Remove(W), Is.False);
var refusal = Assert.Throws<CultSchemaConflictException>(() => a.FlushAsync().GetAwaiter().GetResult())!;
RefusedOnDisk(refusal, W);
Assert.That(StoreOf(a).ForeignRecords, Is.Empty, "no longer foreign");
Assert.That(a.Get(W), Is.Not.Null, "held, read-only");
Assert.Throws<CultSchemaConflictException>(() => a.Remove(W), "refused at the call now: the cache holds it");
}
// A single-file store holding a variant refuses a write after another writer changed it; a changed key whose record this cache
// does not read is listed as foreign by that refusal, as a load lists it.
[Test]
public void AVariantStoreRefusalListsTheRecordItRefusedAsForeign([Values(Write.Flush, Write.Commit)] Write write)
{
var path = PathOf("variant-refusal.cc");
var gears = CultDocumentRegistry.ForTypes(new[] { typeof(VariantTests.VariantGear) });
var withWidget = CultDocumentRegistry.ForTypes(new[] { typeof(VariantTests.VariantGear), Widget });
using var a = Open(path, gears, directory: false);
var baseKey = new CultRecordKey("laser");
a.Commit(batch =>
{
batch.Upsert(typeof(VariantTests.VariantGear), new VariantTests.VariantGear { Name = "laser", Power = 10, Code = "l1" }, baseKey);
batch.UpsertVariant(new CultRecordKey("laser-big"), baseKey, new[]
{
a.Override<VariantTests.VariantGear>(nameof(VariantTests.VariantGear.Name), "laser big"),
a.Override<VariantTests.VariantGear>(nameof(VariantTests.VariantGear.Power), 25),
a.Override<VariantTests.VariantGear>(nameof(VariantTests.VariantGear.Code), "laser big")
});
});
using (var b = Open(path, withWidget, directory: false))
b.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W));
Assert.That(StoreOf(a).ForeignRecords, Is.Empty);
var conflict = Assert.Throws<CultWriteConflictException>(() =>
{
var next = new VariantTests.VariantGear { Name = "next", Power = 1, Code = "n" };
var key = new CultRecordKey("n");
if (write == Write.Flush)
{
a.UpsertAsync(typeof(VariantTests.VariantGear), next, key).GetAwaiter().GetResult();
a.FlushAsync().GetAwaiter().GetResult();
}
else
{
a.Commit(batch => batch.Upsert(typeof(VariantTests.VariantGear), next, key));
}
})!;
Assert.That(conflict.ChangedKeys, Does.Contain(W.Value));
var listed = StoreOf(a).ForeignRecords.Single();
Assert.That((listed.Key, listed.SchemaId), Is.EqualTo((W.Value, WidgetId)), "the refused record is listed as the store holds it");
Assert.That(a.Get(W), Is.Null);
}
private static CacheBackingStore StoreAt(string path, bool directory, bool readOnly) => directory
? new DirectoryMessagePackBackingStore(path, readOnly: readOnly)
: new SingleFileMessagePackBackingStore(path, readOnly);
// A batch with nothing to write and no key to check commits through no store: a staged write waits for its own flush.
[Test]
public void ABatchWithNothingToWriteOrCheckCommitsNothingToTheStore([Values] bool directory)
{
var path = Seeded("empty-batch.cc", directory);
using var cache = Open(path, DeckOnly, directory);
cache.UpsertAsync(Deck, DeckOf("e"), E).GetAwaiter().GetResult();
Assert.That(cache.Commit(batch => { }), Is.True);
Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "w" }), "e is still only staged");
cache.FlushAsync().GetAwaiter().GetResult();
Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "w" }));
}
// A write that only checked keys has nothing to write: no file is replaced and no generation is written, by a commit or a flush.
[Test]
public void AWriteThatOnlyCheckedKeysWritesNoFile([Values] bool directory, [Values] bool viaCommit)
{
var path = Seeded("check-only.cc", directory);
using var cache = Open(path, DeckOnly, directory);
var past = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc);
File.SetLastWriteTimeUtc(path, past);
var before = Fingerprint(path);
if (viaCommit)
Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("absent"))), Is.True);
else
{
Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False);
cache.FlushAsync().GetAwaiter().GetResult();
}
Assert.That(Fingerprint(path), Is.EqualTo(before));
Assert.That(File.GetLastWriteTimeUtc(path), Is.EqualTo(past), "the store was not replaced");
Assert.That(cache.IsDirty, Is.False);
}
// A flush or commit that only checked keys clears every check it judged: the next one has nothing to judge, so it does not
// refuse a record another writer put at a checked key afterwards.
[Test]
public void AWriteThatOnlyCheckedKeysClearsTheChecksItJudged([Values] bool directory, [Values] bool viaCommit)
{
var path = Seeded("check-cleared.cc", directory);
using var cache = Open(path, DeckOnly, directory);
var staged = new CultRecordKey("staged");
Assert.That(cache.Remove(staged), Is.False);
if (viaCommit)
Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("requested"))), Is.True);
else
cache.FlushAsync().GetAwaiter().GetResult();
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Widget, WidgetOf("now a widget"), staged));
Assert.DoesNotThrow(() => cache.FlushAsync().GetAwaiter().GetResult());
}
// A disposed cache and a disposed store judge the checks a removal staged, as their flush does.
[Test]
public void DisposingAStoreJudgesTheChecksARemovalStaged([Values] bool directory, [Values] bool viaCache)
{
var path = Seeded("dispose.cc", directory);
var cache = Open(path, DeckOnly, directory);
cache.FlushAttachedStoresOnDispose = true;
StoreOf(cache).FlushOnDispose = true;
Assert.That(cache.Remove(W), Is.False);
var before = Fingerprint(path);
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
{
if (viaCache)
cache.Dispose();
else
StoreOf(cache).Dispose();
})!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(Fingerprint(path), Is.EqualTo(before));
try { cache.Dispose(); } catch (CultSchemaConflictException) { }
}
// A read-only store has no lock to judge under, so it answers a removal of a key the cache does not hold at the call, from
// what it last loaded.
[Test]
public void AReadOnlyStoreRefusesTheRemovalOfAForeignRecordAtTheCall([Values] bool directory)
{
var path = Seeded("read-only.cc", directory);
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = DeckOnly, UseDirectoryStore = directory, ReadOnly = true });
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Remove(W))!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False);
}
// A batch that only checks keys commits through the one writable store; a read-only store answers the checks from its last load.
[Test]
public void AReadOnlyStoreAnswersTheChecksFromItsView([Values] bool directory)
{
var writable = PathOf("home.cc");
using (var seed = Open(writable, Full, directory))
seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
var readOnly = PathOf("other.cc");
using (var seed = Open(readOnly, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory))
seed.Commit(batch =>
{
batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g"));
batch.Upsert(Widget, WidgetOf("w"), W);
});
using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo }));
cache.AddBackingStore(StoreAt(writable, directory, readOnly: false), Deck);
cache.AddBackingStore(StoreAt(readOnly, directory, readOnly: true), Gizmo);
var before = Fingerprint(writable);
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Remove(W)))!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(Fingerprint(writable), Is.EqualTo(before));
Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("absent"))), Is.True, "a key no store holds is free");
}
// The home store judges a check under its lock, from the record it holds then: a foreign record it listed at the load that
// another writer since replaced with a declared one no longer refuses the commit, and is not removed.
[Test]
public void TheHomeStoreJudgesACheckUnderItsLockNotFromItsLoad([Values] bool directory)
{
var path = Seeded("home-judges.cc", directory);
using var cache = Open(path, DeckOnly, directory);
Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
using (var other = Open(path, Full, directory))
other.Commit(batch => batch.Upsert(Deck, DeckOf("theirs"), W));
var theirs = Stored(path, W);
Assert.That(cache.Commit(batch => batch.Remove(W)), Is.True);
Assert.That(Stored(path, W), Is.EqualTo(theirs), "the declared record the checked key now holds is not removed");
}
// A cache disposed with several stores flushes when any one of them has checks staged, not only when all do (a read-only store
// never has any).
[Test]
public void DisposingACacheFlushesWhenOnlyOneStoreHasChecksStaged([Values] bool directory)
{
var writable = Seeded("dispose-home.cc", directory);
var other = PathOf("dispose-other.cc");
using (var seed = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo }), directory))
seed.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g")));
var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo })) { FlushAttachedStoresOnDispose = true };
cache.AddBackingStore(StoreAt(writable, directory, readOnly: false), Deck);
cache.AddBackingStore(StoreAt(other, directory, readOnly: true), Gizmo);
Assert.That(cache.Remove(W), Is.False);
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Dispose())!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
try { cache.Dispose(); } catch (CultSchemaConflictException) { }
}
// Two writable stores: home.cc holds a deck and takes Deck, other.cc holds a gizmo and takes Gizmo, and, when asked, a widget at
// w that no registered type declares, so the cache lists it foreign at the load.
private (CultCache Cache, string Home, string Other) TwoWritableStores(bool directory, bool otherListsWidget)
{
var home = PathOf("home.cc");
using (var seed = Open(home, DeckOnly, directory))
seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
var other = PathOf("other.cc");
using (var seed = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory))
{
seed.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g")));
if (otherListsWidget)
seed.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W));
}
var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo }));
cache.AddBackingStore(StoreAt(home, directory, readOnly: false), Deck);
cache.AddBackingStore(StoreAt(other, directory, readOnly: false), Gizmo);
return (cache, home, other);
}
// Every writable store judges a batch's checks under its own lock, whichever store the batch commits through: a record another
// writer put at a checked key in a store the batch does not write, after the load, refuses the whole commit and writes nothing.
[Test]
public void EveryWritableStoreRefusesACommitThatRemovesItsLateForeignRecord([Values] bool directory, [Values] bool withWrite)
{
var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: false);
using var _ = cache;
using (var late = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory))
late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W));
var homeBefore = Fingerprint(home);
var otherBefore = Fingerprint(other);
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch =>
{
if (withWrite)
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
batch.Remove(W);
}))!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId)));
Assert.That(Fingerprint(home), Is.EqualTo(homeBefore));
Assert.That(Fingerprint(other), Is.EqualTo(otherBefore));
Assert.That(Read(home).Records.Select(record => record.Key), Does.Not.Contain("d2"));
Assert.That(cache.BackingStores[1].ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
}
// The non-home judgement reads the store under its lock, not its listing: a foreign record that store listed at the load,
// since replaced by a declared one, no longer refuses the commit and is not removed.
[Test]
public void ANonHomeStoreJudgesACheckUnderItsLockNotFromItsLoad([Values] bool directory)
{
var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: true);
using var _ = cache;
Assert.That(cache.BackingStores[1].ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
using (var late = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory))
late.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "theirs")), W));
var theirs = Stored(other, W);
Assert.That(cache.Commit(batch =>
{
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
batch.Remove(W);
}), Is.True);
Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2"));
Assert.That(Stored(other, W), Is.EqualTo(theirs), "the declared record the checked key now holds is not removed");
}
// A store that is not the batch's home and cannot take its lock makes the commit contended before the home writes.
[Test]
public void ANonHomeStoreHeldByAnotherWriterMakesTryCommitContended()
{
var (cache, home, other) = TwoWritableStores(directory: false, otherListsWidget: false);
using var _ = cache;
var homeBefore = Fingerprint(home);
using (new FileStream(other + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
var outcome = cache.TryCommit(batch =>
{
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
batch.Remove(W);
});
Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Contended));
}
Assert.That(Fingerprint(home), Is.EqualTo(homeBefore));
}
private static void CopyStoreOver(string from, string to, bool directory)
{
if (directory)
{
var source = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(from);
var target = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(to);
Directory.CreateDirectory(target);
foreach (var file in Directory.GetFiles(source, "*.msgpack"))
File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
}
File.Copy(from, to, true);
}
private static FileStream HoldStoreLock(string path, bool directory) => directory
? new FileStream(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)
: new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
// A non-home store is judged only after the commit holds that store's own lock: a foreign record another writer lands at the
// checked key while the commit waits for the lock is refused, whichever kind the store is and whether or not the batch writes.
[Test]
public void ANonHomeStoreIsReadOnlyAfterItsLockIsTaken([Values] bool directory, [Values] bool withWrite)
{
var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: false);
using var _ = cache;
var copy = PathOf("copy.cc");
CopyStoreOver(other, copy, directory);
using (var late = Open(copy, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory))
late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W));
var homeBefore = Fingerprint(home);
Exception? thrown = null;
var held = HoldStoreLock(other, directory);
var worker = new System.Threading.Thread(() =>
{
try
{
cache.Commit(batch =>
{
if (withWrite)
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
batch.Remove(W);
});
}
catch (Exception ex) { thrown = ex; }
});
worker.Start();
// The lock wait is a Thread.Sleep retry loop, so the worker sleeping is the worker waiting for the held lock.
Assert.That(System.Threading.SpinWait.SpinUntil(
() => (worker.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10)), Is.True, "the commit waits on the held lock");
CopyStoreOver(copy, other, directory);
var otherAfterSwap = Fingerprint(other);
held.Dispose();
Assert.That(worker.Join(TimeSpan.FromSeconds(40)), Is.True, "the commit finished");
Assert.That(thrown, Is.TypeOf<CultSchemaConflictException>());
Assert.That(Fingerprint(home), Is.EqualTo(homeBefore), "the home is unwritten");
Assert.That(Fingerprint(other), Is.EqualTo(otherAfterSwap), "the non-home store is unwritten");
}
// A directory store judging a check honours wait=false: its lease held by another writer makes TryCommit contended at once.
[Test]
public void ADirectoryNonHomeStoreWhoseLeaseIsHeldMakesTryCommitContended()
{
var (cache, home, other) = TwoWritableStores(directory: true, otherListsWidget: false);
using var _ = cache;
var homeBefore = Fingerprint(home);
CultCommitOutcome outcome;
using (HoldStoreLock(other, directory: true))
outcome = cache.TryCommit(batch =>
{
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
batch.Remove(W);
});
Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Contended));
Assert.That(Fingerprint(home), Is.EqualTo(homeBefore));
}
// A non-home store the cache attached before anything was written to it has no directory and no lease yet: judging a check
// there creates what it locks, finds nothing, and writes no store.
[Test]
public void ANonHomeStoreNothingWasEverWrittenToJudgesAChecksOnlyCommit([Values] bool directory)
{
var home = PathOf("home.cc");
using (var seed = Open(home, DeckOnly, directory))
seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
var other = PathOf("fresh" + Path.DirectorySeparatorChar + "other.cc");
using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo }));
cache.AddBackingStore(StoreAt(home, directory, readOnly: false), Deck);
cache.AddBackingStore(StoreAt(other, directory, readOnly: false), Gizmo);
Assert.That(cache.Commit(batch =>
{
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
batch.Remove(W);
}), Is.True);
Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2"));
Assert.That(File.Exists(other), Is.False, "judging a check writes no store");
}
// A batch with no check asks no other store anything: a lock another writer holds on a store the batch does not write does
// not stand in its way.
[Test]
public void ABatchWithoutChecksIsNotHeldUpByAnotherStoreLock()
{
var (cache, home, other) = TwoWritableStores(directory: false, otherListsWidget: false);
using var _ = cache;
using (new FileStream(other + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
Assert.That(cache.TryCommit(batch => batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"))), Is.EqualTo(CultCommitOutcome.Committed));
Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2"));
}
// A foreign record a refusal lists carries the version the refusal read, so a conditional commit over the stores that
// list it holds when nothing has changed since.
[Test]
public void ExpectUnchangedHoldsAfterARefusalListedTheForeignRecord([Values] bool directory)
{
var path = PathOf("refusal-listed.cc");
using (var seed = Open(path, Full, directory))
seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D));
using var cache = Open(path, DeckOnly, directory);
using (var late = Open(path, Full, directory))
late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W));
Assert.That(cache.Remove(W), Is.False);
Assert.Throws<CultSchemaConflictException>(() => cache.FlushAsync().GetAwaiter().GetResult());
Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" }));
Assert.That(cache.Commit(batch =>
{
batch.ExpectUnchanged();
batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"));
}), Is.True);
}
}
}