CultLib/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
Meta Vens e79f3ee370 Journal tests: pin the survivors Stryker found in the changed journal code
Restart over a retained log resumes above its highest sequence and reads its
latest from the durable log; put and delete doors log their own wire messages
and change kinds; a reconciled put is logged as an update; a replica's
watermark follows the primary's sequences; an admission racing disposal logs
nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 00:52:02 +02:00

571 lines
28 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
using R3;
using static GameCult.Networking.Tests.NetworkingTests;
namespace GameCult.Networking.Tests
{
// The database's shard log is written by a cache journal, under the cache gate, in cache order. A primary that
// cannot log a committed change burns that change's sequence and compacts past it, so the log can lag the cache but
// never lead it, and every replica behind the hole resynchronizes by snapshot.
public sealed class CultNetJournalTests
{
private const string ShardId = "log-shard";
private static readonly CultRecordKey One = new("tests:journal:one");
private static readonly CultRecordKey Two = new("tests:journal:two");
private static readonly CultRecordKey Three = new("tests:journal:three");
private static NetworkSchemaNote Note(string text) => new() { Schema = "tests.networking_note.v1", Text = text };
private static string SchemaId(CultCache cache) => cache.Registry.GetRequired<NetworkSchemaNote>().SchemaId;
private static CultNetShardDescriptor Shard(CultCache cache, bool primary) =>
new(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [SchemaId(cache)]);
private static CultNetDatabase Database(CultCache cache, bool primary, FlakyLogStore? store) =>
new(cache, new CultNetDatabaseOptions { Shards = [Shard(cache, primary)], MutationLogStore = store });
private static string KeyOfChange(object change) => ((dynamic)change).Key.Value;
private static List<string> Record(CultNetDatabase database)
{
var seen = new List<string>();
database.WatchAllChanges().Subscribe(change => seen.Add(KeyOfChange(change)));
return seen;
}
private static long[] Sequences(FlakyLogStore store) => store.Read(ShardId).Select(entry => entry.Sequence).ToArray();
// A store whose refusals and side effects the test scripts. One shard per store.
private sealed class FlakyLogStore : ICultNetShardMutationLogStore
{
private readonly object _gate = new();
private readonly SortedDictionary<long, CultNetShardLogEntryMessage> _entries = new();
private long _compactedThrough;
public Func<long, bool> RefuseAppend { get; set; } = _ => false;
public bool RefuseCompact { get; set; }
public Action? OnRead { get; set; }
public Action? OnAppend { get; set; }
public IReadOnlyList<CultNetShardLogEntryMessage> Read(string shardId, long afterSequence = 0, int? limit = null)
{
OnRead?.Invoke();
lock (_gate)
{
var entries = _entries.Values.Where(entry => entry.Sequence > afterSequence);
return (limit.HasValue ? entries.Take(limit.Value) : entries).ToArray();
}
}
public void Append(string shardId, CultNetShardLogEntryMessage entry)
{
OnAppend?.Invoke();
lock (_gate)
{
if (RefuseAppend(entry.Sequence))
throw new IOException($"The log refused sequence {entry.Sequence}.");
_entries[entry.Sequence] = entry;
}
}
public long GetCompactedThrough(string shardId)
{
lock (_gate)
return _compactedThrough;
}
public void CompactThrough(string shardId, long sequence)
{
lock (_gate)
{
if (RefuseCompact)
throw new IOException("The log refused to compact.");
_compactedThrough = Math.Max(_compactedThrough, sequence);
foreach (var stale in _entries.Keys.Where(key => key <= sequence).ToArray())
_entries.Remove(stale);
}
}
}
// The primary as a replica meets it: the database server's own log answer, and the database's own snapshot.
private sealed class PrimaryPeer : ICultNetShardLogFetcher, ICultNetShardSnapshotFetcher, IDisposable
{
private readonly Server _server;
private readonly CultNetDatabaseServer _databaseServer;
private readonly CultNetDatabase _database;
public PrimaryPeer(CultCache cache, CultNetDatabase database)
{
_database = database;
_server = new Server(cache, ServerSecurityOptions.Development());
_databaseServer = new CultNetDatabaseServer(_server, database);
}
public CultNetShardLogResponseMessage Log(long afterSequence) =>
_databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
MessageId = "pull",
ShardId = ShardId,
ShardEpoch = 1,
AfterSequence = afterSequence
});
public Task<CultNetShardLogResponseMessage> FetchAsync(CultNetShardDescriptor shard, long afterSequence, int? limit = null) =>
Task.FromResult(Log(afterSequence));
public Task<CultNetSnapshotResponseRawMessage> FetchAsync(CultNetShardDescriptor shard) =>
Task.FromResult(_database.CreateShardSnapshotResponse(_database.Shards[0], "snapshot"));
public void Dispose()
{
_databaseServer.Dispose();
_server.Dispose();
}
}
private static CultNetShardReplicator Replicator(CultNetDatabase replica, PrimaryPeer peer) =>
new(replica, new CultNetShardReplicatorOptions { Fetcher = peer, SnapshotFetcher = peer });
private static string? TextOf(CultCache cache, CultRecordKey key) => cache.Get<NetworkSchemaNote>(key)?.Text;
[Test]
public async Task ARefusedPrimaryAppendPublishesTheChangeTheWriterHearsOfItAndAReplicaBehindItResyncs()
{
var store = new FlakyLogStore { RefuseAppend = sequence => sequence == 2 };
var cache = new CultCache();
var primary = Database(cache, primary: true, store);
using var peer = new PrimaryPeer(cache, primary);
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, store: null);
using var replicator = Replicator(replica, peer);
await primary.PutAsync(One, Note("one"));
Assert.That(await replicator.PullOnceAsync(ShardId), Is.EqualTo(1));
var published = Record(primary);
var failure = Assert.ThrowsAsync<CultNetShardLogException>(async () => await primary.PutAsync(Two, Note("two")))!;
Assert.That(failure.ShardId, Is.EqualTo(ShardId));
Assert.That(failure.Sequence, Is.EqualTo(2));
Assert.That(failure, Is.Not.InstanceOf<InvalidOperationException>(), "a committed write must not read as a refused one");
Assert.That(published, Is.EqualTo(new[] { Two.Value }), "the change was published before the writer heard");
Assert.That(TextOf(cache, Two), Is.EqualTo("two"), "the commit stands");
Assert.That(primary.LastWriteSequence(SchemaId(cache), Two), Is.EqualTo(2L), "the row was written at the burned sequence");
await primary.PutAsync(Three, Note("three"));
Assert.That(Sequences(store), Is.EqualTo(new[] { 3L }), "sequence 2 stays a hole below the compaction point");
Assert.That(peer.Log(afterSequence: 1).Reason, Is.EqualTo("compacted_history"));
Assert.That(await replicator.PullOnceAsync(ShardId), Is.EqualTo(3));
Assert.That(new[] { One, Two, Three }.Select(key => TextOf(replicaCache, key)), Is.EqualTo(new[] { "one", "two", "three" }));
Assert.That(await replicator.PullOnceAsync(ShardId), Is.EqualTo(3), "a later pull is a quiet no-op");
}
[Test]
public async Task ARestartAfterARefusedTailAppendReusesNoSequence()
{
var store = new FlakyLogStore { RefuseAppend = sequence => sequence == 2 };
var cache = new CultCache();
var primary = Database(cache, primary: true, store);
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, store: null);
using (var before = new PrimaryPeer(cache, primary))
{
await primary.PutAsync(One, Note("one"));
using var replicator = Replicator(replica, before);
await replicator.PullOnceAsync(ShardId);
}
Assert.ThrowsAsync<CultNetShardLogException>(async () => await primary.PutAsync(Two, Note("two")));
primary.Dispose();
var restarted = Database(cache, primary: true, store);
await restarted.PutAsync(Three, Note("three"));
Assert.That(Sequences(store), Is.EqualTo(new[] { 3L }), "the burned sequence was not minted again");
using var after = new PrimaryPeer(cache, restarted);
using var resumed = Replicator(replica, after);
await resumed.PullOnceAsync(ShardId);
Assert.That(new[] { One, Two, Three }.Select(key => TextOf(replicaCache, key)), Is.EqualTo(new[] { "one", "two", "three" }));
}
[Test]
public async Task AStoreThatRefusesTheAppendAndTheCompactionStillCompactsInMemoryAndNamesBothFailures()
{
var store = new FlakyLogStore { RefuseAppend = sequence => sequence == 2, RefuseCompact = true };
var cache = new CultCache();
var primary = Database(cache, primary: true, store);
using var peer = new PrimaryPeer(cache, primary);
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, store: null);
using var replicator = Replicator(replica, peer);
await primary.PutAsync(One, Note("one"));
await replicator.PullOnceAsync(ShardId);
var failure = Assert.ThrowsAsync<CultNetShardLogException>(async () => await primary.PutAsync(Two, Note("two")))!;
Assert.That(failure.InnerException, Is.InstanceOf<AggregateException>());
Assert.That(((AggregateException)failure.InnerException!).InnerExceptions, Has.Count.EqualTo(2));
Assert.That(primary.GetCompactedMutationLogSequence(ShardId), Is.EqualTo(2), "the primary's own floor covers a hole its store never compacted");
Assert.That(primary.GetLatestMutationLogSequence(ShardId), Is.EqualTo(2), "the latest sequence is never below the floor");
Assert.That(peer.Log(afterSequence: 1).Reason, Is.EqualTo("compacted_history"));
Assert.That(await replicator.PullOnceAsync(ShardId), Is.EqualTo(2));
Assert.That(TextOf(replicaCache, Two), Is.EqualTo("two"));
}
[Test]
public async Task CompactingAndRestartingResumesTheNextSequenceAboveTheCompactionPoint()
{
var store = new FlakyLogStore();
var cache = new CultCache();
var primary = Database(cache, primary: true, store);
await primary.PutAsync(One, Note("one"));
await primary.PutAsync(Two, Note("two"));
store.CompactThrough(ShardId, 2);
primary.Dispose();
var restarted = Database(cache, primary: true, store);
Assert.That(restarted.GetLatestMutationLogSequence(ShardId), Is.EqualTo(2), "the latest sequence is never below the compaction point");
await restarted.PutAsync(Three, Note("three"));
Assert.That(Sequences(store), Is.EqualTo(new[] { 3L }));
Assert.That(restarted.GetLatestMutationLogSequence(ShardId), Is.EqualTo(3));
}
[Test]
public async Task ARefusedReplicaAppendRecordsNothingAdvancesNoCursorAndTheRetryConverges()
{
var refusals = 0;
var replicaStore = new FlakyLogStore { RefuseAppend = sequence => sequence == 1 && refusals++ == 0 };
var cache = new CultCache();
var primary = Database(cache, primary: true, new FlakyLogStore());
await primary.PutAsync(One, Note("one"));
await primary.PutAsync(Two, Note("two"));
using var peer = new PrimaryPeer(cache, primary);
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, replicaStore);
using var replicator = Replicator(replica, peer);
Assert.ThrowsAsync<IOException>(async () => await replicator.PullOnceAsync(ShardId));
Assert.That(replica.GetAppliedShardSequence(ShardId), Is.EqualTo(0));
Assert.That(replica.GetMutationLog(ShardId), Is.Empty, "memory never holds what the durable log refused");
Assert.That(await replicator.PullOnceAsync(ShardId), Is.EqualTo(2));
Assert.That(replica.GetMutationLog(ShardId).Select(entry => entry.Sequence), Is.EqualTo(new[] { 1L, 2L }));
Assert.That(Sequences(replicaStore), Is.EqualTo(new[] { 1L, 2L }));
}
[Test]
public async Task AReentrantShardLogApplyFromAHandlerKeepsTheReplicaLogInPrimaryOrder()
{
var cache = new CultCache();
var primary = Database(cache, primary: true, new FlakyLogStore());
await primary.PutAsync(One, Note("one"));
await primary.PutAsync(Two, Note("two"));
await primary.PutAsync(Three, Note("three"));
var response = new CultNetShardLogResponseMessage
{
ShardId = ShardId,
ShardEpoch = 1,
Entries = primary.GetMutationLogMessages(ShardId).ToArray()
};
var replicaStore = new FlakyLogStore();
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, replicaStore);
var nested = false;
long nestedApplied = 0;
Exception? nestedFailure = null;
replica.Watch<NetworkSchemaNote>().Where(change => change.Key.Equals(One)).Subscribe(_ =>
{
if (nested) return;
nested = true;
try
{
nestedApplied = replica.ApplyShardLogResponseAsync(response).GetAwaiter().GetResult();
}
catch (Exception exception)
{
nestedFailure = exception;
}
});
await replica.ApplyShardLogResponseAsync(response);
Assert.That(nested, Is.True);
Assert.That(nestedFailure, Is.Null, "the nested apply re-records entries the outer apply already logged");
Assert.That(nestedApplied, Is.EqualTo(3));
Assert.That(replica.GetMutationLog(ShardId).Select(entry => entry.Sequence), Is.EqualTo(new[] { 1L, 2L, 3L }));
Assert.That(Sequences(replicaStore), Is.EqualTo(new[] { 1L, 2L, 3L }));
Assert.That(replica.GetAppliedShardSequence(ShardId), Is.EqualTo(3));
Assert.That(replica.CurrentAsOf(ShardId), Is.EqualTo(3UL), "the replica's own watermark follows the primary's sequences");
}
[Test]
public async Task AReentrantDatabaseWriteFromAHandlerIsLoggedWithItsContextAndPublishedOnce()
{
var store = new FlakyLogStore();
var cache = new CultCache();
var database = Database(cache, primary: true, store);
var message = database.Documents.CreateRawDocumentPutMessage(
"reentrant-two", new CultRecordHandle<NetworkSchemaNote>(Two), Note("two"));
var published = Record(database);
database.Watch<NetworkSchemaNote>().Where(change => change.Key.Equals(One)).Subscribe(_ =>
database.ApplyPutAsync(message).GetAwaiter().GetResult());
await database.PutAsync(One, Note("one"));
Assert.That(published, Is.EqualTo(new[] { One.Value, Two.Value }));
Assert.That(database.GetMutationLog(ShardId).Select(entry => entry.Key.Value), Is.EqualTo(new[] { One.Value, Two.Value }));
Assert.That(store.Read(ShardId).Single(entry => entry.Sequence == 2).Put!.MessageId, Is.EqualTo("reentrant-two"),
"the nested write's own wire message is what the log stored");
}
[Test]
public async Task ALogStoreThatWritesTheCacheFailsThroughTheReentryGuardAndTheAdmissionStands()
{
var cache = new CultCache();
var store = new FlakyLogStore();
store.OnAppend = () =>
{
store.OnAppend = null;
cache.UpsertAsync(Note("from the store"), new CultRecordHandle<NetworkSchemaNote>(Three)).GetAwaiter().GetResult();
};
var database = Database(cache, primary: true, store);
var published = Record(database);
var failure = Assert.ThrowsAsync<CultNetShardLogException>(async () => await database.PutAsync(One, Note("one")))!;
Assert.That(failure.InnerException, Is.InstanceOf<InvalidOperationException>(), "the cache's guard refused the store's write");
Assert.That(TextOf(cache, One), Is.EqualTo("one"));
Assert.That(TextOf(cache, Three), Is.Null);
Assert.That(published, Is.EqualTo(new[] { One.Value }));
}
[Test]
public void SeveralRefusedAppendsInOneAdmissionAreAllPublishedAndAllReachTheWriter()
{
var store = new FlakyLogStore { RefuseAppend = _ => true };
var cache = new CultCache();
var database = Database(cache, primary: true, store);
var published = Record(database);
var failure = Assert.Throws<AggregateException>(() => cache.Commit(batch =>
{
batch.Upsert(Note("one"), new CultRecordHandle<NetworkSchemaNote>(One));
batch.Upsert(Note("two"), new CultRecordHandle<NetworkSchemaNote>(Two));
}))!;
Assert.That(failure.InnerExceptions.OfType<CultNetShardLogException>().Select(exception => exception.Sequence), Is.EqualTo(new[] { 1L, 2L }));
Assert.That(published, Is.EqualTo(new[] { One.Value, Two.Value }));
Assert.That(database.GetCompactedMutationLogSequence(ShardId), Is.EqualTo(2));
}
[Test]
public async Task APullOnAPrimaryIsLoggedSoAReplicaConverges()
{
var path = Path.Combine(Path.GetTempPath(), $"cultnet-journal-pull-{Guid.NewGuid():N}.cc");
try
{
var writer = new CultCache();
writer.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await writer.UpsertAsync(Note("seed"), new CultRecordHandle<NetworkSchemaNote>(One));
writer.FlushAllBackingStores();
var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
var primary = Database(cache, primary: true, new FlakyLogStore());
using var peer = new PrimaryPeer(cache, primary);
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, store: null);
using var replicator = Replicator(replica, peer);
await writer.UpsertAsync(Note("pulled"), new CultRecordHandle<NetworkSchemaNote>(Two));
writer.FlushAllBackingStores();
await cache.PullAllBackingStoresAsync();
await replicator.PullOnceAsync(ShardId);
Assert.That(primary.GetMutationLog(ShardId).Select(entry => entry.Key.Value), Does.Contain(Two.Value));
Assert.That(TextOf(replicaCache, Two), Is.EqualTo("pulled"));
}
finally
{
if (File.Exists(path)) File.Delete(path);
if (File.Exists(path + ".lock")) File.Delete(path + ".lock");
}
}
[Test]
public async Task AStoreAttachedAfterTheDatabaseExistsHasItsHydrationLogged()
{
var path = Path.Combine(Path.GetTempPath(), $"cultnet-journal-attach-{Guid.NewGuid():N}.cc");
try
{
var writer = new CultCache();
writer.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await writer.UpsertAsync(Note("one"), new CultRecordHandle<NetworkSchemaNote>(One));
await writer.UpsertAsync(Note("two"), new CultRecordHandle<NetworkSchemaNote>(Two));
writer.FlushAllBackingStores();
var cache = new CultCache();
var database = Database(cache, primary: true, new FlakyLogStore());
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
Assert.That(database.GetMutationLog(ShardId).Select(entry => entry.Key.Value), Is.EquivalentTo(new[] { One.Value, Two.Value }));
}
finally
{
if (File.Exists(path)) File.Delete(path);
if (File.Exists(path + ".lock")) File.Delete(path + ".lock");
}
}
[Test]
public async Task ASnapshotReadsItsLogSequenceBeforeItsDocumentsSoARacingWriteConverges()
{
var store = new FlakyLogStore();
var cache = new CultCache();
var primary = Database(cache, primary: true, store);
await primary.PutAsync(One, Note("one"));
using var peer = new PrimaryPeer(cache, primary);
var replicaCache = new CultCache();
var replica = Database(replicaCache, primary: false, store: null);
using var replicator = Replicator(replica, peer);
store.OnRead = () =>
{
store.OnRead = null;
primary.PutAsync(Two, Note("two")).GetAwaiter().GetResult();
};
var snapshot = primary.CreateShardSnapshotResponse(primary.Shards[0], "snapshot");
await replica.ApplyShardSnapshotResponseAsync(replica.Shards[0], snapshot);
await replicator.PullOnceAsync(ShardId);
Assert.That(TextOf(replicaCache, Two), Is.EqualTo("two"), "a write racing the snapshot reaches the replica by one road or the other");
}
[Test]
public async Task ASnapshotAppliedToAPrimaryShardLogsNothing()
{
var cache = new CultCache();
await cache.UpsertAsync(Note("stale"), new CultRecordHandle<NetworkSchemaNote>(One));
var database = Database(cache, primary: true, new FlakyLogStore());
var put = database.Documents.CreateRawDocumentPutMessage("snapshot-two", new CultRecordHandle<NetworkSchemaNote>(Two), Note("two"));
await database.ApplyShardSnapshotResponseAsync(database.Shards[0], new CultNetSnapshotResponseRawMessage
{
ShardId = ShardId,
ShardEpoch = 1,
Documents = [put.Document!],
ShardLogSequence = 5
});
Assert.That(TextOf(cache, One), Is.Null);
Assert.That(TextOf(cache, Two), Is.EqualTo("two"));
Assert.That(database.GetMutationLog(ShardId), Is.Empty, "the snapshot's removal and its put are both a resync, not a commit");
}
[Test]
public async Task ARestartOverARetainedLogResumesAboveItsHighestSequence()
{
var store = new FlakyLogStore();
var cache = new CultCache();
var primary = Database(cache, primary: true, store);
await primary.PutAsync(One, Note("one"));
await primary.PutAsync(Two, Note("two"));
primary.Dispose();
var restarted = Database(cache, primary: true, store);
Assert.That(restarted.GetLatestMutationLogSequence(ShardId), Is.EqualTo(2), "a restarted database reads its latest sequence from the durable log");
await restarted.PutAsync(Three, Note("three"));
Assert.That(Sequences(store), Is.EqualTo(new[] { 1L, 2L, 3L }));
}
[Test]
public async Task APutAndDeleteDoorLogTheirOwnWireMessagesAndChangeKinds()
{
var store = new FlakyLogStore();
var cache = new CultCache();
var database = Database(cache, primary: true, store);
var added = database.Documents.CreateRawDocumentPutMessage("put-added", new CultRecordHandle<NetworkSchemaNote>(One), Note("one"));
var updated = database.Documents.CreateRawDocumentPutMessage("put-updated", new CultRecordHandle<NetworkSchemaNote>(One), Note("uno"));
await database.ApplyPutAsync(added);
await database.ApplyPutAsync(updated);
await database.ApplyDeleteAsync(new CultNetDocumentDeleteMessage { MessageId = "delete-one", SchemaId = SchemaId(cache), RecordKey = One.Value });
var entries = store.Read(ShardId);
Assert.That(entries.Select(entry => entry.ChangeKind), Is.EqualTo(new[] { "added", "updated", "removed" }));
Assert.That(entries[0].Put!.MessageId, Is.EqualTo("put-added"));
Assert.That(entries[1].Put!.MessageId, Is.EqualTo("put-updated"));
Assert.That(entries[2].Delete!.MessageId, Is.EqualTo("delete-one"));
Assert.That(DateTimeOffset.TryParse(entries[0].CommittedAt, out _), Is.True, "a primary stamps its entries");
}
[Test]
public async Task AReconciledPutIsLoggedAsTheUpdateItIs()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
RuntimeId = "local",
ClientAuthorityScopes = [new CultNetClientAuthorityScope("local")]
});
var shard = database.Shards[0];
var message = database.Documents.CreateRawDocumentPutMessage("authoritative", new CultRecordHandle<NetworkSchemaNote>(One), Note("authoritative"));
message.ShardId = shard.ShardId;
message.ShardEpoch = shard.Epoch;
var published = new List<CultNetDatabaseChangeKind>();
database.Watch<NetworkSchemaNote>().Subscribe(change => published.Add(change.Kind));
await database.PutPredictedAsync(One, Note("predicted"));
await database.ApplyPutAsync(message);
Assert.That(published, Is.EqualTo(new[] { CultNetDatabaseChangeKind.Predicted, CultNetDatabaseChangeKind.Reconciled }));
Assert.That(database.GetMutationLog(shard.ShardId).Select(entry => entry.Kind), Is.EqualTo(new[] { CultNetDatabaseChangeKind.Updated }),
"a prediction is never logged and its reconciliation is an ordinary update");
}
[Test]
public async Task AnAdmissionRacingDisposalLogsNothingAfterTheDatabaseIsDisposed()
{
var store = new FlakyLogStore();
var cache = new CultCache();
var entered = new System.Threading.ManualResetEventSlim();
CultNetDatabase? database = null;
// Registered before the database, so it runs first inside the writer's hold, while another thread disposes the database.
using var holdUntilDisposed = cache.AddJournal(_ =>
{
entered.Set();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
try
{
database!.GetLatestMutationLogSequence(ShardId);
}
catch (ObjectDisposedException)
{
return;
}
System.Threading.Thread.Sleep(1);
}
});
database = Database(cache, primary: true, store);
var writer = Task.Run(() => database.PutAsync(One, Note("one")));
Assert.That(entered.Wait(TimeSpan.FromSeconds(5)), Is.True);
database.Dispose();
await writer;
Assert.That(Sequences(store), Is.Empty, "the admission was journaled after disposal began and must not reach the durable log");
}
}
}