Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
#nullable enable
|
|
|
|
|
using System;
|
2026-09-13 19:46:18 +02:00
|
|
|
using System.Collections.Concurrent;
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using GameCult.Caching.MessagePack;
|
|
|
|
|
using MessagePack;
|
|
|
|
|
using NUnit.Framework;
|
|
|
|
|
using R3;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Caching.Tests
|
|
|
|
|
{
|
|
|
|
|
public class ConditionalCommitTests
|
|
|
|
|
{
|
|
|
|
|
private static readonly CultRecordKey Key = new("counter");
|
|
|
|
|
private string _directory = string.Empty;
|
|
|
|
|
|
|
|
|
|
[SetUp]
|
|
|
|
|
public void CreateDirectory()
|
|
|
|
|
{
|
|
|
|
|
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-conditional-{Guid.NewGuid():N}");
|
|
|
|
|
Directory.CreateDirectory(_directory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TearDown]
|
|
|
|
|
public void DeleteDirectory()
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(_directory))
|
|
|
|
|
Directory.Delete(_directory, recursive: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void StaleExpectFailsWithNothingChanged() => StaleExpect(SingleFile(PathOf("store.cc")));
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ExpectNullIsCreateOnce() => CreateOnce(SingleFile(PathOf("store.cc")));
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void PerEntryCommitSurvivesUnrelatedWrite() => UnrelatedWrite(SingleFile(PathOf("store.cc")));
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ExpectUnchangedFailsOnUnrelatedInsert() => UnrelatedInsert(SingleFile(PathOf("store.cc")));
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void DirectoryStoreHonorsConditions()
|
|
|
|
|
{
|
|
|
|
|
StaleExpect(DirectoryStore(PathOf("stale.cc")));
|
|
|
|
|
CreateOnce(DirectoryStore(PathOf("create.cc")));
|
|
|
|
|
UnrelatedWrite(DirectoryStore(PathOf("unrelated.cc")));
|
|
|
|
|
UnrelatedInsert(DirectoryStore(PathOf("insert.cc")));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
// ---- a record loaded under compatible schema drift: written under v1, read by a cache that knows only v2 ----
|
|
|
|
|
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public void UnchangedDriftedRecordSatisfiesExpect(bool directory)
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory);
|
|
|
|
|
using var cache = Drift(directory, NarrowDrift).Open();
|
|
|
|
|
|
|
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "an unchanged drifted record failed Expect");
|
|
|
|
|
using var reader = Drift(directory, NarrowDrift).Open();
|
|
|
|
|
Assert.That(reader.Get<ConditionalDriftNarrow>(DriftKey)!.Value, Is.EqualTo(2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public void ValueChangedDriftedRecordFailsExpect(bool directory)
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory);
|
|
|
|
|
using var cache = Drift(directory, NarrowDrift).Open();
|
|
|
|
|
var observed = cache.Get(DriftKey)!;
|
|
|
|
|
using (var other = Drift(directory, NarrowDrift).Open())
|
|
|
|
|
Assert.That(other.Commit(batch => batch.Upsert(new ConditionalDriftNarrow { Name = "drift", Value = 9 }, new CultRecordHandle<ConditionalDriftNarrow>(DriftKey))), Is.True);
|
|
|
|
|
|
|
|
|
|
Assert.That(DriftCommit(cache, observed, 2), Is.False, "a value another writer changed passed Expect");
|
|
|
|
|
using var reader = Drift(directory, NarrowDrift).Open();
|
|
|
|
|
Assert.That(reader.Get<ConditionalDriftNarrow>(DriftKey)!.Value, Is.EqualTo(9));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
2026-10-22 21:06:10 +02:00
|
|
|
public void RecordRewrittenUnderAnotherSchemaFailsExpect()
|
2026-10-21 21:02:57 +02:00
|
|
|
{
|
|
|
|
|
SeedDrift(directory: false);
|
|
|
|
|
using var cache = Drift(false, NarrowDrift).Open();
|
|
|
|
|
var observed = cache.Get(DriftKey)!;
|
|
|
|
|
using (var other = Drift(false, ReusedDrift).Open())
|
2026-10-22 20:59:54 +02:00
|
|
|
{
|
|
|
|
|
other.BackingStores[0].Push(other.GetStored(DriftKey)!);
|
2026-10-21 21:02:57 +02:00
|
|
|
other.BackingStores[0].PushAll();
|
2026-10-22 20:59:54 +02:00
|
|
|
}
|
2026-10-21 21:02:57 +02:00
|
|
|
|
2026-10-22 21:06:10 +02:00
|
|
|
Assert.That(DriftCommit(cache, observed, 2), Is.False, "a record rewritten under another schema passed Expect");
|
2026-10-21 21:02:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:06:29 +02:00
|
|
|
// The directory store rewrites only what is pushed to it, so the other writer pushes the record it read unchanged.
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public void AnotherWritersNormalisingFlushFailsExpectUntilReRead(bool directory)
|
2026-10-21 21:02:57 +02:00
|
|
|
{
|
2026-10-21 21:06:29 +02:00
|
|
|
SeedDrift(directory);
|
|
|
|
|
using var cache = Drift(directory, NarrowDrift).Open();
|
2026-10-21 21:02:57 +02:00
|
|
|
var observed = cache.Get(DriftKey)!;
|
2026-10-21 21:06:29 +02:00
|
|
|
using (var other = Drift(directory, NarrowDrift).Open())
|
|
|
|
|
{
|
|
|
|
|
other.BackingStores[0].Push(other.GetStored(DriftKey)!);
|
2026-10-21 21:02:57 +02:00
|
|
|
other.BackingStores[0].PushAll();
|
2026-10-21 21:06:29 +02:00
|
|
|
}
|
2026-10-21 21:02:57 +02:00
|
|
|
|
|
|
|
|
Assert.That(DriftCommit(cache, observed, 2), Is.False, "Expect held across a rewrite under a schema id this cache did not read");
|
|
|
|
|
cache.BackingStores[0].PullAll();
|
|
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 3), Is.True, "a re-read did not refresh the persisted schema id");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void OwnFlushKeepsDriftedExpectHolding()
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory: false);
|
|
|
|
|
using var cache = Drift(false, NarrowDrift).Open();
|
|
|
|
|
cache.BackingStores[0].PushAll();
|
|
|
|
|
|
|
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after this cache's own flush rewrote the record");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:40:08 +02:00
|
|
|
// A store rewrites what is pushed to it under the entry's descriptor; the entry then names that schema id.
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public void OwnPushOfALoadedDriftedEntryKeepsExpectHolding(bool directory)
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory);
|
|
|
|
|
using var cache = Drift(directory, NarrowDrift).Open();
|
|
|
|
|
cache.BackingStores[0].Push(cache.GetStored(DriftKey)!);
|
|
|
|
|
cache.BackingStores[0].PushAll();
|
|
|
|
|
|
|
|
|
|
var stored = cache.GetStored(DriftKey)!;
|
2026-10-22 20:21:31 +02:00
|
|
|
Assert.That(stored.StoredSchemaId, Is.EqualTo(stored.Descriptor.SchemaId), "the rewrite was not recorded");
|
2026-10-21 21:40:08 +02:00
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after this cache's own push rewrote the record");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
[Test]
|
|
|
|
|
public void OwnUnconditionalCommitKeepsDriftedExpectHolding()
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory: false);
|
|
|
|
|
using var cache = Drift(false, NarrowDrift).Open();
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new ConditionalDriftNarrow { Name = "other" }, new CultRecordHandle<ConditionalDriftNarrow>(new CultRecordKey("other")))), Is.True);
|
|
|
|
|
|
|
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after this cache's own whole-file commit rewrote the record");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public void ExpectUnchangedHoldsOnAnUntouchedDriftedStore(bool directory)
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory);
|
|
|
|
|
using var cache = Drift(directory, NarrowDrift).Open();
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.ExpectUnchanged();
|
|
|
|
|
batch.Upsert(new ConditionalDriftNarrow { Name = "other" }, new CultRecordHandle<ConditionalDriftNarrow>(new CultRecordKey("other")));
|
|
|
|
|
}), Is.True, "ExpectUnchanged failed on a store nobody touched");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:05:23 +02:00
|
|
|
[Test]
|
|
|
|
|
public void UpsertUnderTheCurrentSchemaMovesThePersistedId()
|
|
|
|
|
{
|
|
|
|
|
SeedDrift(directory: false);
|
2026-10-21 21:06:51 +02:00
|
|
|
string wide;
|
|
|
|
|
using (var writer = Drift(false, WideDrift).Open())
|
|
|
|
|
wide = writer.GetStored(DriftKey)!.Descriptor.SchemaId;
|
2026-10-21 21:05:23 +02:00
|
|
|
using var cache = Drift(false, NarrowDrift).Open();
|
|
|
|
|
var loaded = cache.GetStored(DriftKey)!;
|
2026-10-22 20:21:31 +02:00
|
|
|
Assert.That(loaded.StoredSchemaId, Is.EqualTo(wide), "the entry carries the schema id its record is stored under");
|
2026-10-21 21:06:51 +02:00
|
|
|
Assert.That(loaded.Descriptor.SchemaId, Is.Not.EqualTo(wide));
|
2026-10-21 21:05:23 +02:00
|
|
|
|
|
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True);
|
2026-10-21 21:06:51 +02:00
|
|
|
var written = cache.GetStored(DriftKey)!;
|
2026-10-22 20:21:31 +02:00
|
|
|
Assert.That(written.StoredSchemaId, Is.EqualTo(written.Descriptor.SchemaId));
|
2026-10-21 21:05:23 +02:00
|
|
|
Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 3), Is.True, "Expect failed on the record this cache just wrote");
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
// The lock is a FileShare.None handle, which excludes another handle in this process exactly as it does one in
|
|
|
|
|
// another process, so two store instances on two threads prove the property without a process boundary.
|
|
|
|
|
[Test]
|
|
|
|
|
public void TwoWritersRacingExactlyOneWins()
|
|
|
|
|
{
|
|
|
|
|
const int n = 100;
|
|
|
|
|
var store = SingleFile(PathOf("race.cc"));
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using var b = store.Open();
|
|
|
|
|
|
|
|
|
|
Assert.That(Increment(a), Is.True);
|
|
|
|
|
Assert.That(Increment(b), Is.False, "b observed before a committed and still won");
|
|
|
|
|
b.PullAllBackingStoresAsync();
|
|
|
|
|
Assert.That(Increment(b), Is.True);
|
|
|
|
|
Assert.That(Increment(a), Is.False, "a committed over b's newer record from its stale view");
|
|
|
|
|
|
|
|
|
|
var racers = new[] { a, b }.Select(cache => Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
var wins = 0;
|
2026-09-13 18:21:19 +02:00
|
|
|
var mismatches = 0;
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
for (var attempt = 0; wins < n && attempt < 50 * n; attempt++)
|
|
|
|
|
{
|
|
|
|
|
cache.PullAllBackingStoresAsync();
|
|
|
|
|
if (Increment(cache))
|
|
|
|
|
wins++;
|
2026-09-13 18:21:19 +02:00
|
|
|
else
|
|
|
|
|
mismatches++;
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 18:21:19 +02:00
|
|
|
return (Wins: wins, Mismatches: mismatches);
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
})).ToArray();
|
2026-09-13 18:21:19 +02:00
|
|
|
Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(2)), Is.True, "the racers did not finish");
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
|
2026-09-13 18:21:19 +02:00
|
|
|
Assert.That(racers.Select(racer => racer.Result.Wins), Is.All.EqualTo(n));
|
|
|
|
|
Assert.That(racers.Sum(racer => racer.Result.Mismatches), Is.GreaterThan(0), "the writers never actually raced");
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
using var reader = store.Open();
|
|
|
|
|
Assert.That(reader.Get<Counter>(Key)!.Value, Is.EqualTo(2 + 2 * n));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryCommitReportsContended()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("contended.cc");
|
|
|
|
|
var store = SingleFile(path);
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var cache = store.Open();
|
|
|
|
|
var before = File.ReadAllBytes(path);
|
|
|
|
|
Task<bool> waiting;
|
|
|
|
|
|
|
|
|
|
using (new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
|
|
|
|
{
|
|
|
|
|
Assert.That(cache.TryCommit(batch => batch.Upsert(new Counter { Name = "x" }, new CultRecordHandle<Counter>(new CultRecordKey("x")))),
|
|
|
|
|
Is.EqualTo(CultCommitOutcome.Contended));
|
|
|
|
|
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
|
|
|
|
Assert.That(cache.Get(new CultRecordKey("x")), Is.Null);
|
|
|
|
|
|
|
|
|
|
waiting = Task.Run(() => cache.Commit(batch => batch.Upsert(new Counter { Name = "y" }, new CultRecordHandle<Counter>(new CultRecordKey("y")))));
|
|
|
|
|
Assert.That(waiting.Wait(200), Is.False, "Commit did not wait for the held lock");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(waiting.Wait(TimeSpan.FromSeconds(10)) && waiting.Result, Is.True);
|
|
|
|
|
Assert.That(cache.Get(new CultRecordKey("y")), Is.Not.Null);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 18:21:19 +02:00
|
|
|
[Test]
|
|
|
|
|
public void TryCommitIsNotContendedByAConcurrentReader()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("reader.cc");
|
|
|
|
|
var store = SingleFile(path);
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var cache = store.Open();
|
|
|
|
|
var handle = new CultRecordHandle<Counter>(new CultRecordKey("read-under"));
|
|
|
|
|
var running = true;
|
|
|
|
|
var reads = 0;
|
|
|
|
|
using var started = new ManualResetEventSlim();
|
|
|
|
|
var reading = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
started.Set();
|
|
|
|
|
while (Volatile.Read(ref running))
|
|
|
|
|
{
|
|
|
|
|
_ = cache.AllStoredDocuments.Count();
|
|
|
|
|
Interlocked.Increment(ref reads);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Assert.That(started.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
|
for (var i = 0; i < 200; i++)
|
|
|
|
|
Assert.That(cache.TryCommit(batch => batch.Upsert(new Counter { Name = "read-under", Value = i }, handle)),
|
|
|
|
|
Is.EqualTo(CultCommitOutcome.Committed), $"attempt {i} lost to a reader, not a writer");
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Volatile.Write(ref running, false);
|
|
|
|
|
Assert.That(reading.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(reads, Is.GreaterThan(0));
|
|
|
|
|
using (new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
|
|
|
|
Assert.That(cache.TryCommit(batch => batch.Upsert(new Counter { Name = "read-under" }, handle)),
|
|
|
|
|
Is.EqualTo(CultCommitOutcome.Contended));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void PushAllWaitsForHeldStoreLock()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("push-wait.cc");
|
|
|
|
|
using var cache = SingleFile(path).Open();
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "pushed" }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
Task flush;
|
|
|
|
|
|
|
|
|
|
using (new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
|
|
|
|
{
|
|
|
|
|
flush = Task.Run(() => cache.FlushAllBackingStores());
|
|
|
|
|
Assert.That(flush.Wait(150), Is.False, "PushAll did not wait for the held store lock");
|
|
|
|
|
Assert.That(File.Exists(path), Is.False);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(flush.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
|
using var reader = SingleFile(path).Open();
|
|
|
|
|
Assert.That(reader.Get(Key), Is.Not.Null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void CommitWaitsForHeldStoreLock()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("commit-wait.cc");
|
|
|
|
|
using var cache = SingleFile(path).Open();
|
|
|
|
|
Task<bool> commit;
|
|
|
|
|
|
|
|
|
|
using (new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
|
|
|
|
{
|
|
|
|
|
commit = Task.Run(() => CreateCommit(cache, 7));
|
|
|
|
|
Assert.That(commit.Wait(150), Is.False, "Commit did not wait for the held store lock");
|
|
|
|
|
Assert.That(File.Exists(path), Is.False);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(commit.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
|
Assert.That(commit.Result, Is.True);
|
|
|
|
|
using var reader = SingleFile(path).Open();
|
|
|
|
|
Assert.That(reader.Get<Counter>(Key)!.Value, Is.EqualTo(7));
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
[Test]
|
|
|
|
|
public void PlainFlushNeverInterleaves()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("interleave.cc");
|
|
|
|
|
var store = SingleFile(path);
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var flusher = store.Open();
|
|
|
|
|
using var committer = store.Open();
|
|
|
|
|
var running = true;
|
|
|
|
|
var reads = 0;
|
|
|
|
|
|
|
|
|
|
var flushing = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
|
|
|
{
|
|
|
|
|
flusher.UpsertAsync(new Counter { Name = "flushed", Value = i }, new CultRecordHandle<Counter>(new CultRecordKey("flushed")));
|
|
|
|
|
flusher.FlushAllBackingStores();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var committing = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
|
|
|
{
|
|
|
|
|
var key = new CultRecordKey($"committed:{i}");
|
|
|
|
|
committer.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(key, null);
|
|
|
|
|
batch.Upsert(new Counter { Name = key.Value, Value = i }, new CultRecordHandle<Counter>(key));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var reading = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
while (Volatile.Read(ref running))
|
|
|
|
|
{
|
|
|
|
|
byte[] bytes;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
|
|
|
using var buffer = new MemoryStream();
|
|
|
|
|
stream.CopyTo(buffer);
|
|
|
|
|
bytes = buffer.ToArray();
|
|
|
|
|
}
|
|
|
|
|
catch (IOException)
|
|
|
|
|
{
|
|
|
|
|
// An open that loses to a replace in flight (moved away, or held by the replace) is not a read.
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
|
|
|
|
|
reads++;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-13 18:21:19 +02:00
|
|
|
Assert.That(Task.WaitAll(new[] { flushing, committing }, TimeSpan.FromMinutes(2)), Is.True, "the writers did not finish");
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
Volatile.Write(ref running, false);
|
|
|
|
|
Assert.That(() => reading.Wait(TimeSpan.FromSeconds(10)), Throws.Nothing);
|
|
|
|
|
Assert.That(reads, Is.GreaterThan(0));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 08:23:31 +02:00
|
|
|
// With no backing store a record's version is minted with it: an Expect on the held record commits, and once the record is
|
|
|
|
|
// replaced after it was observed (here by the same document again) the Expect no longer holds.
|
|
|
|
|
[Test]
|
|
|
|
|
public void InMemoryExpectHoldsUntilTheRecordIsReplaced()
|
|
|
|
|
{
|
|
|
|
|
using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Counter) }));
|
|
|
|
|
cache.Commit(batch => batch.Upsert(new Counter { Name = "counter", Value = 1 }, new CultRecordHandle<Counter>(Key)));
|
|
|
|
|
var first = cache.Get<Counter>(Key)!;
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Expect(Key, first)), Is.True);
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(Key, first);
|
|
|
|
|
cache.UpsertAsync(first, new CultRecordHandle<Counter>(Key)).GetAwaiter().GetResult();
|
|
|
|
|
}), Is.False);
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
[Test]
|
|
|
|
|
public void StoredAtIsStrictlyIncreasingPerKey()
|
|
|
|
|
{
|
|
|
|
|
using var cache = SingleFile(PathOf("stamps.cc")).Open();
|
|
|
|
|
var stamps = new string[1000];
|
|
|
|
|
for (var i = 0; i < stamps.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "stamped", Value = i }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
stamps[i] = cache.AllStoredDocuments.Single().StoredAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(stamps.Distinct().Count(), Is.EqualTo(stamps.Length));
|
|
|
|
|
var instants = stamps.Select(stamp => DateTimeOffset.Parse(stamp, null, System.Globalization.DateTimeStyles.RoundtripKind)).ToArray();
|
|
|
|
|
Assert.That(instants.Zip(instants.Skip(1), (earlier, later) => later > earlier), Is.All.True);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void DirtyStoreRefusesConditionalCommit()
|
|
|
|
|
{
|
|
|
|
|
using var cache = SingleFile(PathOf("dirty.cc")).Open();
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "staged" });
|
|
|
|
|
|
|
|
|
|
Assert.That(() => cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(Key, null);
|
|
|
|
|
batch.Upsert(new Counter { Name = "counter" }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
}), Throws.InvalidOperationException.With.Message.Contains("flush"));
|
|
|
|
|
Assert.That(cache.Get(Key), Is.Null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void CrossStoreConditionalBatchThrowsBeforeAnyStore()
|
|
|
|
|
{
|
|
|
|
|
using var cache = new CultCache(Registry);
|
|
|
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("counters.cc")), typeof(Counter));
|
|
|
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("tallies.cc")), typeof(Tally));
|
|
|
|
|
|
|
|
|
|
Assert.That(() => cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(Key, null);
|
|
|
|
|
batch.Upsert(new Counter { Name = "counter" }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
batch.Upsert(new Tally { Name = "tally" }, new CultRecordHandle<Tally>(new CultRecordKey("tally")));
|
|
|
|
|
}), Throws.InvalidOperationException.With.Message.Contains("one home store"));
|
|
|
|
|
Assert.That(System.IO.Directory.GetFiles(_directory), Is.Empty, "a store file or lock file was touched");
|
|
|
|
|
}
|
|
|
|
|
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
// Cache A pulls and upserts on two threads while cache B commits to the same directory store; the pull's load
|
|
|
|
|
// callback and the upsert's push must take the cache gate and the store lock in one order. A is not disposed on
|
|
|
|
|
// failure because a deadlocked gate would hang the test thread too.
|
|
|
|
|
[Test]
|
|
|
|
|
public void PullAndUpsertOnDirectoryStoreDoNotDeadlock()
|
|
|
|
|
{
|
|
|
|
|
var store = DirectoryStore(PathOf("deadlock.cc"));
|
|
|
|
|
var a = store.Open();
|
|
|
|
|
using var b = store.Open();
|
|
|
|
|
var pulling = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < 300; i++)
|
|
|
|
|
a.PullAllBackingStoresAsync();
|
|
|
|
|
});
|
|
|
|
|
var upserting = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < 3000; i++)
|
|
|
|
|
a.UpsertAsync(new Counter { Name = $"a:{i}" }, new CultRecordHandle<Counter>(new CultRecordKey($"a:{i}")));
|
|
|
|
|
});
|
|
|
|
|
var writing = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < 150; i++)
|
|
|
|
|
{
|
|
|
|
|
b.UpsertAsync(new Counter { Name = $"b:{i}" }, new CultRecordHandle<Counter>(new CultRecordKey($"b:{i}")));
|
|
|
|
|
b.FlushAllBackingStores();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Assert.That(Task.WaitAll(new[] { pulling, upserting, writing }, TimeSpan.FromSeconds(60)), Is.True, "pull and upsert deadlocked");
|
|
|
|
|
a.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void RepullNeverErasesAStagedWrite()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("repull.cc");
|
|
|
|
|
Create(SingleFile(path), 0);
|
|
|
|
|
var store = new PausingStore(path);
|
|
|
|
|
using var cache = new CultCache(Registry);
|
|
|
|
|
cache.AddBackingStore(store);
|
|
|
|
|
var staged = new CultRecordKey("staged");
|
|
|
|
|
|
|
|
|
|
using (var release = new ManualResetEventSlim())
|
|
|
|
|
{
|
|
|
|
|
store.Pause = release;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var pulling = Task.Run(() => cache.PullAllBackingStoresAsync());
|
|
|
|
|
Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the pull never read the file");
|
2026-09-13 20:01:36 +02:00
|
|
|
using var started = new ManualResetEventSlim();
|
|
|
|
|
var writing = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
started.Set();
|
|
|
|
|
return cache.UpsertAsync(new Counter { Name = "staged" }, new CultRecordHandle<Counter>(staged));
|
|
|
|
|
});
|
|
|
|
|
Assert.That(started.Wait(TimeSpan.FromSeconds(10)), Is.True, "the writer never started");
|
2026-09-13 19:46:18 +02:00
|
|
|
writing.Wait(200);
|
|
|
|
|
Assert.That(store.PushedWhilePaused, Is.False, "a write reached the store while the pull held the gate");
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
store.Pause = null;
|
|
|
|
|
release.Set();
|
2026-09-13 19:46:18 +02:00
|
|
|
Assert.That(Task.WaitAll(new Task[] { pulling, writing }, TimeSpan.FromSeconds(10)), Is.True);
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
store.Pause = null;
|
|
|
|
|
release.Set();
|
|
|
|
|
}
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Get(staged), Is.Not.Null, "the re-pull erased a staged write from the cache");
|
|
|
|
|
cache.FlushAllBackingStores();
|
|
|
|
|
using var reader = SingleFile(path).Open();
|
|
|
|
|
Assert.That(reader.Get(staged), Is.Not.Null, "the flush after the re-pull lost the staged write");
|
|
|
|
|
}
|
|
|
|
|
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
// The pull pauses inside the directory store's own page load, under the commit lease.
|
|
|
|
|
[Test]
|
|
|
|
|
public void RepullNeverErasesAStagedWriteOnDirectoryStore()
|
|
|
|
|
{
|
|
|
|
|
var store = DirectoryStore(PathOf("repull-dir.cc"));
|
|
|
|
|
using (var seed = store.Open())
|
|
|
|
|
Assert.That(seed.Commit(batch => batch.Upsert(new Slow { Name = "slow", Blocks = true })), Is.True);
|
|
|
|
|
using var cache = store.Open();
|
|
|
|
|
var staged = new CultRecordKey("staged");
|
|
|
|
|
|
|
|
|
|
using (var release = new ManualResetEventSlim())
|
|
|
|
|
{
|
|
|
|
|
Slow.Entered.Reset();
|
2026-09-13 19:46:18 +02:00
|
|
|
Slow.ReadWhilePaused = false;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
Slow.Pause = release;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var pulling = Task.Run(() => cache.PullAllBackingStoresAsync());
|
|
|
|
|
Assert.That(Slow.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the pull never loaded a page");
|
2026-09-13 19:46:18 +02:00
|
|
|
// Admitting the write indexes its name under the gate, so a write that gets past the gate reads it while paused.
|
2026-09-13 20:01:36 +02:00
|
|
|
using var started = new ManualResetEventSlim();
|
|
|
|
|
var writing = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
started.Set();
|
|
|
|
|
return cache.UpsertAsync(new Slow { Name = "staged" }, new CultRecordHandle<Slow>(staged));
|
|
|
|
|
});
|
|
|
|
|
Assert.That(started.Wait(TimeSpan.FromSeconds(10)), Is.True, "the writer never started");
|
2026-09-13 19:46:18 +02:00
|
|
|
writing.Wait(200);
|
|
|
|
|
Assert.That(Slow.ReadWhilePaused, Is.False, "a write was admitted while the pull held the gate");
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
Slow.Pause = null;
|
|
|
|
|
release.Set();
|
2026-09-13 19:46:18 +02:00
|
|
|
Assert.That(Task.WaitAll(new Task[] { pulling, writing }, TimeSpan.FromSeconds(10)), Is.True);
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Slow.Pause = null;
|
|
|
|
|
release.Set();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Get(staged), Is.Not.Null, "the re-pull erased a staged write from the cache");
|
|
|
|
|
cache.FlushAllBackingStores();
|
|
|
|
|
using var reader = store.Open();
|
|
|
|
|
Assert.That(reader.Get(staged), Is.Not.Null, "the flush after the re-pull lost the staged write");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Gate sharing is what makes a direct store call wait for its cache's I/O instead of interleaving with it.
|
|
|
|
|
[Test]
|
|
|
|
|
public void DirectCallsOnAnAttachedStoreShareTheCacheGate()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("direct.cc");
|
|
|
|
|
Create(SingleFile(path), 0);
|
|
|
|
|
var store = new PausingStore(path);
|
|
|
|
|
using var cache = new CultCache(Registry);
|
|
|
|
|
cache.AddBackingStore(store);
|
|
|
|
|
|
|
|
|
|
using var release = new ManualResetEventSlim();
|
|
|
|
|
store.Pause = release;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var pulling = Task.Run(() => cache.PullAllBackingStoresAsync());
|
|
|
|
|
Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the pull never read the file");
|
|
|
|
|
var pushing = Task.Run(() => store.PushAll());
|
|
|
|
|
Assert.That(pushing.Wait(200), Is.False, "a direct PushAll ran while the cache's pull held the gate");
|
|
|
|
|
store.Pause = null;
|
|
|
|
|
release.Set();
|
|
|
|
|
Assert.That(Task.WaitAll(new[] { pulling, pushing }, TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
// A failed assert must not leave the pull parked on the gate that Dispose needs.
|
|
|
|
|
store.Pause = null;
|
|
|
|
|
release.Set();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ObserverDoesNotRunUnderTheGate()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("observer.cc");
|
|
|
|
|
using var cache = SingleFile(path).Open();
|
|
|
|
|
var gated = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
|
|
|
|
var seen = 0;
|
|
|
|
|
using var subscription = cache.Watch<Counter>().Subscribe(change =>
|
|
|
|
|
{
|
|
|
|
|
Interlocked.Increment(ref seen);
|
|
|
|
|
if (!Task.Run(() => cache.Get(change.Key)).Wait(TimeSpan.FromSeconds(5)))
|
|
|
|
|
gated.Enqueue(change.Key.Value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "upserted" }, new CultRecordHandle<Counter>(new CultRecordKey("upserted")));
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new Counter { Name = "committed" }, new CultRecordHandle<Counter>(new CultRecordKey("committed")))), Is.True);
|
|
|
|
|
using (var other = SingleFile(path).Open())
|
|
|
|
|
Assert.That(other.Commit(batch => batch.Upsert(new Counter { Name = "pulled" }, new CultRecordHandle<Counter>(new CultRecordKey("pulled")))), Is.True);
|
|
|
|
|
cache.PullAllBackingStoresAsync();
|
|
|
|
|
|
|
|
|
|
Assert.That(seen, Is.EqualTo(3));
|
|
|
|
|
Assert.That(gated, Is.Empty, "an observer ran while the cache gate was held");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord()
|
|
|
|
|
{
|
|
|
|
|
var store = SingleFile(PathOf("resurrect.cc"));
|
|
|
|
|
var doomed = new CultRecordKey("doomed");
|
|
|
|
|
var later = new CultRecordKey("later");
|
|
|
|
|
using (var seed = store.Open())
|
|
|
|
|
Assert.That(seed.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Upsert(new Counter { Name = "doomed" }, new CultRecordHandle<Counter>(doomed));
|
|
|
|
|
batch.Upsert(new Counter { Name = "kept" });
|
|
|
|
|
}), Is.True);
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using (var b = store.Open())
|
|
|
|
|
{
|
|
|
|
|
Assert.That(b.Remove(doomed), Is.True);
|
|
|
|
|
b.FlushAllBackingStores();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var throwing = true;
|
|
|
|
|
a.OnUpdate += (_, _) =>
|
|
|
|
|
{
|
|
|
|
|
if (throwing)
|
|
|
|
|
throw new InvalidOperationException("observer failed");
|
|
|
|
|
};
|
|
|
|
|
Assert.That(() => a.PullAllBackingStoresAsync(), Throws.InvalidOperationException.With.Message.EqualTo("observer failed"));
|
|
|
|
|
throwing = false;
|
|
|
|
|
Assert.That(a.Get(doomed), Is.Null);
|
|
|
|
|
|
|
|
|
|
a.UpsertAsync(new Counter { Name = "later" }, new CultRecordHandle<Counter>(later));
|
|
|
|
|
a.FlushAllBackingStores();
|
|
|
|
|
using var reader = store.Open();
|
|
|
|
|
Assert.That(reader.Get(later), Is.Not.Null);
|
|
|
|
|
Assert.That(reader.Get(doomed), Is.Null, "a throwing observer left the store holding a record the cache dropped");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 19:46:18 +02:00
|
|
|
[Test]
|
|
|
|
|
public void OwnChangeIsPublishedBeforeWriteReturns()
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
{
|
2026-09-13 19:46:18 +02:00
|
|
|
using var cache = SingleFile(PathOf("own.cc")).Open();
|
|
|
|
|
var published = new ConcurrentDictionary<string, int>();
|
|
|
|
|
using var subscription = cache.Watch<Counter>().Subscribe(change => published[change.Key.Value] = Environment.CurrentManagedThreadId);
|
|
|
|
|
var late = 0;
|
|
|
|
|
var writers = Enumerable.Range(0, 4).Select(t => Task.Run(() =>
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
{
|
2026-09-13 19:46:18 +02:00
|
|
|
for (var i = 0; i < 3000; i++)
|
|
|
|
|
{
|
|
|
|
|
var key = $"{t}:{i}";
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = key }, new CultRecordHandle<Counter>(new CultRecordKey(key)));
|
|
|
|
|
if (!published.TryGetValue(key, out var thread) || thread != Environment.CurrentManagedThreadId)
|
|
|
|
|
Interlocked.Increment(ref late);
|
|
|
|
|
}
|
|
|
|
|
})).ToArray();
|
|
|
|
|
|
|
|
|
|
Assert.That(Task.WaitAll(writers, TimeSpan.FromSeconds(60)), Is.True);
|
|
|
|
|
Assert.That(late, Is.Zero, "a write returned before its own change was published on its thread");
|
|
|
|
|
Assert.That(published.Count, Is.EqualTo(12000));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ThrowingObserverDoesNotLoseOtherChanges()
|
|
|
|
|
{
|
|
|
|
|
var store = DirectoryStore(PathOf("throwing.cc"));
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using var other = store.Open();
|
|
|
|
|
var loads = 0;
|
|
|
|
|
var armed = 0;
|
|
|
|
|
a.OnUpdate += (_, _) =>
|
|
|
|
|
{
|
|
|
|
|
Interlocked.Increment(ref loads);
|
|
|
|
|
if (Interlocked.Exchange(ref armed, 0) == 1)
|
|
|
|
|
throw new InvalidOperationException("observer failed");
|
|
|
|
|
};
|
|
|
|
|
var published = new ConcurrentDictionary<string, int>();
|
|
|
|
|
using var subscription = a.Watch<Counter>().Subscribe(change => published[change.Key.Value] = Environment.CurrentManagedThreadId);
|
|
|
|
|
|
|
|
|
|
var failed = 0;
|
|
|
|
|
var late = 0;
|
|
|
|
|
using var stop = new CancellationTokenSource();
|
|
|
|
|
var writing = Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; !stop.IsCancellationRequested; i++)
|
|
|
|
|
{
|
|
|
|
|
var key = $"w:{i}";
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
a.UpsertAsync(new Counter { Name = key }, new CultRecordHandle<Counter>(new CultRecordKey(key)));
|
|
|
|
|
if (!published.TryGetValue(key, out var thread) || thread != Environment.CurrentManagedThreadId)
|
|
|
|
|
Interlocked.Increment(ref late);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception)
|
|
|
|
|
{
|
|
|
|
|
Interlocked.Increment(ref failed);
|
|
|
|
|
}
|
|
|
|
|
}
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
});
|
2026-09-13 19:46:18 +02:00
|
|
|
|
|
|
|
|
try
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
{
|
2026-09-13 19:46:18 +02:00
|
|
|
for (var pull = 0; pull < 50; pull++)
|
|
|
|
|
{
|
|
|
|
|
var first = new CultRecordKey($"first:{pull}");
|
|
|
|
|
var second = new CultRecordKey($"second:{pull}");
|
|
|
|
|
Assert.That(other.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Upsert(new Counter { Name = first.Value }, new CultRecordHandle<Counter>(first));
|
|
|
|
|
batch.Upsert(new Counter { Name = second.Value }, new CultRecordHandle<Counter>(second));
|
|
|
|
|
}), Is.True);
|
|
|
|
|
Interlocked.Exchange(ref armed, 1);
|
|
|
|
|
Assert.That(() => a.PullAllBackingStoresAsync(), Throws.InvalidOperationException.With.Message.EqualTo("observer failed"), $"pull {pull}");
|
|
|
|
|
Assert.That(loads, Is.EqualTo(2 * (pull + 1)), $"pull {pull} lost a change after its observer threw");
|
|
|
|
|
Assert.That(published.ContainsKey(second.Value), Is.True, $"pull {pull} never published its second record");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
stop.Cancel();
|
|
|
|
|
Assert.That(writing.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 19:46:18 +02:00
|
|
|
Assert.That(failed, Is.Zero, "an unrelated write threw another call's observer exception");
|
|
|
|
|
Assert.That(late, Is.Zero, "an unrelated write returned before its change was published");
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
}
|
|
|
|
|
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
[Test]
|
2026-10-03 03:20:17 +02:00
|
|
|
public void UnconditionalCommitIsLastWriterWinsPerKeyLikeFlush()
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
{
|
|
|
|
|
var committed = LastWriter(SingleFile(PathOf("commit.cc")), (cache, key) =>
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new Counter { Name = key.Value }, new CultRecordHandle<Counter>(key))), Is.True));
|
|
|
|
|
var flushed = LastWriter(SingleFile(PathOf("flush.cc")), (cache, key) =>
|
|
|
|
|
{
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = key.Value }, new CultRecordHandle<Counter>(key));
|
|
|
|
|
cache.FlushAllBackingStores();
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-03 03:20:17 +02:00
|
|
|
Assert.That(committed, Is.EqualTo(new[] { "a", "c", "x" }), "an unconditional commit dropped a record another writer added");
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
Assert.That(committed, Is.EqualTo(flushed));
|
|
|
|
|
|
2026-10-03 03:20:17 +02:00
|
|
|
// A directory store's flush lands staged keys onto the current manifest, and its commit does exactly the same: so does a single file's.
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
var directoryCommitted = LastWriter(DirectoryStore(PathOf("commit-dir.cc")), (cache, key) =>
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new Counter { Name = key.Value }, new CultRecordHandle<Counter>(key))), Is.True));
|
|
|
|
|
var directoryFlushed = LastWriter(DirectoryStore(PathOf("flush-dir.cc")), (cache, key) =>
|
|
|
|
|
{
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = key.Value }, new CultRecordHandle<Counter>(key));
|
|
|
|
|
cache.FlushAllBackingStores();
|
|
|
|
|
});
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
Assert.That(directoryCommitted, Is.EqualTo(new[] { "a", "c", "x" }), "a directory commit dropped a key another writer added");
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
Assert.That(directoryCommitted, Is.EqualTo(directoryFlushed));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void UnconditionalCommitPersistsStagedWrites()
|
|
|
|
|
{
|
|
|
|
|
var path = PathOf("staged.cc");
|
|
|
|
|
using var cache = SingleFile(path).Open();
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "s" }, new CultRecordHandle<Counter>(new CultRecordKey("s")));
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Upsert(new Counter { Name = "y" }, new CultRecordHandle<Counter>(new CultRecordKey("y")))), Is.True);
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.IsDirty, Is.False);
|
|
|
|
|
using var reader = SingleFile(path).Open();
|
|
|
|
|
Assert.That(reader.AllStoredDocuments.Select(stored => stored.Key.Value), Is.EqualTo(new[] { "s", "y" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void OpeningACorruptStoreThrowsAndLeavesItUntouched()
|
|
|
|
|
{
|
|
|
|
|
foreach (var store in new[] { SingleFile(PathOf("corrupt.cc")), DirectoryStore(PathOf("corrupt-dir.cc")) })
|
|
|
|
|
{
|
|
|
|
|
File.WriteAllBytes(store.Path, new byte[] { 0xC1, 0x00, 0xFF, 0x13 });
|
|
|
|
|
Assert.That(() => store.Open(), Throws.Exception, $"{store.Path}: a corrupt store opened");
|
|
|
|
|
Assert.That(File.ReadAllBytes(store.Path), Is.EqualTo(new byte[] { 0xC1, 0x00, 0xFF, 0x13 }), $"{store.Path}: opening rewrote a corrupt store");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var unknown = PathOf("unknown-schema.cc");
|
|
|
|
|
using (var writer = SingleFile(unknown).Open())
|
|
|
|
|
{
|
|
|
|
|
writer.UpsertAsync(new Tally { Name = "t" });
|
|
|
|
|
writer.FlushAllBackingStores();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 20:52:08 +02:00
|
|
|
// A record of a schema this cache does not register is not corruption: the store opens and carries it (ForeignRecordTests).
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
var bytes = File.ReadAllBytes(unknown);
|
|
|
|
|
using var narrow = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Counter) }));
|
2026-09-30 20:52:08 +02:00
|
|
|
narrow.AddBackingStore(new SingleFileMessagePackBackingStore(unknown));
|
|
|
|
|
Assert.That(narrow.BackingStores.Single().ForeignRecords, Has.Count.EqualTo(1));
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
Assert.That(File.ReadAllBytes(unknown), Is.EqualTo(bytes), "opening rewrote a store with an unknown schema");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A pulls {a}; B commits c; A writes x. Returns the keys on disk afterwards.
|
|
|
|
|
private static string[] LastWriter(StoreFactory store, Action<CultCache, CultRecordKey> write)
|
|
|
|
|
{
|
|
|
|
|
using (var seed = store.Open())
|
|
|
|
|
Assert.That(seed.Commit(batch => batch.Upsert(new Counter { Name = "a" }, new CultRecordHandle<Counter>(new CultRecordKey("a")))), Is.True);
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using (var b = store.Open())
|
|
|
|
|
Assert.That(b.Commit(batch => batch.Upsert(new Counter { Name = "c" }, new CultRecordHandle<Counter>(new CultRecordKey("c")))), Is.True);
|
|
|
|
|
write(a, new CultRecordKey("x"));
|
|
|
|
|
using var reader = store.Open();
|
|
|
|
|
return reader.AllStoredDocuments.Select(stored => stored.Key.Value).ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
private static void StaleExpect(StoreFactory store)
|
|
|
|
|
{
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using var b = store.Open();
|
|
|
|
|
Assert.That(Increment(a), Is.True);
|
|
|
|
|
var before = File.ReadAllBytes(store.Path);
|
|
|
|
|
var observedByB = b.Get<Counter>(Key)!;
|
|
|
|
|
var changes = 0;
|
|
|
|
|
using var subscription = b.Watch<Counter>().Subscribe(_ => changes++);
|
|
|
|
|
|
|
|
|
|
Assert.That(Commit(b, observedByB, 100), Is.False, $"{store.Path}: b's stale Expect committed");
|
|
|
|
|
Assert.That(File.ReadAllBytes(store.Path), Is.EqualTo(before));
|
|
|
|
|
Assert.That(b.Get<Counter>(Key), Is.SameAs(observedByB));
|
|
|
|
|
Assert.That(b.IsDirty, Is.False);
|
|
|
|
|
Assert.That(changes, Is.Zero);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void CreateOnce(StoreFactory store)
|
|
|
|
|
{
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using var b = store.Open();
|
|
|
|
|
Assert.That(CreateCommit(a, 1), Is.True);
|
|
|
|
|
Assert.That(CreateCommit(b, 2), Is.False, $"{store.Path}: a second create-once commit won");
|
|
|
|
|
using var reader = store.Open();
|
|
|
|
|
Assert.That(reader.Get<Counter>(Key)!.Value, Is.EqualTo(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void UnrelatedWrite(StoreFactory store)
|
|
|
|
|
{
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using var b = store.Open();
|
|
|
|
|
Assert.That(a.Commit(batch => batch.Upsert(new Counter { Name = "b" }, new CultRecordHandle<Counter>(new CultRecordKey("b")))), Is.True);
|
|
|
|
|
|
|
|
|
|
Assert.That(Increment(b), Is.True, $"{store.Path}: an unrelated write failed a per-entry condition");
|
|
|
|
|
using var reader = store.Open();
|
|
|
|
|
Assert.That(reader.Get<Counter>(Key)!.Value, Is.EqualTo(1));
|
|
|
|
|
Assert.That(reader.Get(new CultRecordKey("b")), Is.Not.Null, $"{store.Path}: the commit dropped a record it never loaded");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void UnrelatedInsert(StoreFactory store)
|
|
|
|
|
{
|
|
|
|
|
Create(store, 0);
|
|
|
|
|
using var a = store.Open();
|
|
|
|
|
using var b = store.Open();
|
|
|
|
|
Assert.That(a.Commit(batch => batch.Upsert(new Counter { Name = "c" }, new CultRecordHandle<Counter>(new CultRecordKey("c")))), Is.True);
|
|
|
|
|
|
|
|
|
|
Assert.That(b.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.ExpectUnchanged();
|
|
|
|
|
batch.Upsert(new Counter { Name = "counter", Value = 5 }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
}), Is.False, $"{store.Path}: ExpectUnchanged ignored an insert");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void Create(StoreFactory store, int value)
|
|
|
|
|
{
|
|
|
|
|
using var cache = store.Open();
|
|
|
|
|
Assert.That(CreateCommit(cache, value), Is.True);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool CreateCommit(CultCache cache, int value) => cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(Key, null);
|
|
|
|
|
batch.Upsert(new Counter { Name = "counter", Value = value }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private static bool Increment(CultCache cache)
|
|
|
|
|
{
|
|
|
|
|
var observed = cache.Get<Counter>(Key)!;
|
|
|
|
|
return Commit(cache, observed, observed.Value + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool Commit(CultCache cache, Counter observed, int value) => cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(Key, observed);
|
|
|
|
|
batch.Upsert(new Counter { Name = "counter", Value = value }, new CultRecordHandle<Counter>(Key));
|
|
|
|
|
});
|
|
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
// One writer's delivery is parked in an observer; other writers, whose observers write the same cache, finish.
|
|
|
|
|
// The cache is not disposed on failure because a stuck delivery would hang disposal too.
|
2026-09-13 20:09:23 +02:00
|
|
|
[Test]
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
public void ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries()
|
2026-09-13 20:09:23 +02:00
|
|
|
{
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
var cache = new CultCache(Registry);
|
|
|
|
|
using var release = new ManualResetEventSlim();
|
|
|
|
|
using var parked = new ManualResetEventSlim();
|
|
|
|
|
using var park = cache.Watch<Counter>().Subscribe(change =>
|
2026-09-13 20:09:23 +02:00
|
|
|
{
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
if (change.Key.Value != "parked") return;
|
|
|
|
|
parked.Set();
|
|
|
|
|
release.Wait(TimeSpan.FromSeconds(30));
|
2026-09-13 20:09:23 +02:00
|
|
|
});
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
using var tallies = cache.Watch<Counter>().Subscribe(change =>
|
|
|
|
|
cache.UpsertAsync(new Tally { Name = $"t:{change.Key.Value}" }, new CultRecordHandle<Tally>(new CultRecordKey($"t:{change.Key.Value}"))));
|
2026-09-13 20:09:23 +02:00
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
var parkedWriter = Task.Run(() => cache.UpsertAsync(new Counter { Name = "parked" }, new CultRecordHandle<Counter>(new CultRecordKey("parked"))));
|
|
|
|
|
Assert.That(parked.Wait(TimeSpan.FromSeconds(5)), Is.True);
|
2026-09-13 20:09:23 +02:00
|
|
|
var writers = Enumerable.Range(0, 8).Select(thread => Task.Run(() =>
|
|
|
|
|
{
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
for (var i = 0; i < 100; i++)
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = $"c:{thread}", Value = i }, new CultRecordHandle<Counter>(new CultRecordKey($"c:{thread}")));
|
2026-09-13 20:09:23 +02:00
|
|
|
})).ToArray();
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
var finished = Task.WaitAll(writers, TimeSpan.FromSeconds(10));
|
|
|
|
|
release.Set();
|
2026-09-13 20:09:23 +02:00
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
Assert.That(finished, Is.True, "a writer waited on another thread's delivery");
|
|
|
|
|
Assert.That(parkedWriter.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
|
cache.Dispose();
|
2026-09-13 20:09:23 +02:00
|
|
|
}
|
|
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
// Ordered by Sequence, each change replaces the document installed by the change before it.
|
2026-09-13 20:09:23 +02:00
|
|
|
[Test]
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
public void SequenceIncreasesInAdmissionOrder()
|
2026-09-13 20:09:23 +02:00
|
|
|
{
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
using var cache = new CultCache(Registry);
|
|
|
|
|
var key = new CultRecordKey("ordered");
|
|
|
|
|
var observed = new ConcurrentBag<CultCacheDocumentChange<Counter>>();
|
|
|
|
|
using var subscription = cache.Watch<Counter>().Subscribe(observed.Add);
|
2026-09-13 20:09:23 +02:00
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
var writers = Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
|
2026-09-13 20:09:23 +02:00
|
|
|
{
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
for (var i = 0; i < 2000; i++)
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "ordered", Value = i }, new CultRecordHandle<Counter>(key));
|
2026-09-13 20:09:23 +02:00
|
|
|
})).ToArray();
|
|
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
Assert.That(Task.WaitAll(writers, TimeSpan.FromSeconds(60)), Is.True);
|
|
|
|
|
var ordered = observed.OrderBy(change => change.Sequence).ToArray();
|
|
|
|
|
Assert.That(ordered, Has.Length.EqualTo(16000));
|
|
|
|
|
Assert.That(ordered.Select(change => change.Sequence).Distinct().Count(), Is.EqualTo(16000), "a Sequence repeated");
|
|
|
|
|
var broken = Enumerable.Range(1, ordered.Length - 1)
|
|
|
|
|
.FirstOrDefault(i => !ReferenceEquals(ordered[i].PreviousDocument, ordered[i - 1].Document));
|
|
|
|
|
Assert.That(broken, Is.EqualTo(0), "a change's previous document is not the one installed by the next-lower Sequence");
|
2026-09-13 20:09:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 21:02:49 +02:00
|
|
|
// Each read must hold the document of the highest-Sequence change at or below the read's Sequence.
|
|
|
|
|
[Test]
|
|
|
|
|
public void SequencedReadReflectsEveryChangeAtOrBelowItsSequence()
|
|
|
|
|
{
|
|
|
|
|
using var cache = new CultCache(Registry);
|
|
|
|
|
var key = new CultRecordKey("sequenced");
|
|
|
|
|
var handle = new CultRecordHandle<Counter>(key);
|
|
|
|
|
var changes = new ConcurrentBag<CultCacheDocumentChange<Counter>>();
|
|
|
|
|
using var subscription = cache.Watch<Counter>().Subscribe(changes.Add);
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "sequenced", Value = -1 }, handle);
|
|
|
|
|
|
|
|
|
|
var reads = new ConcurrentBag<(object? Document, long Sequence)>();
|
|
|
|
|
var writers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < 2000; i++)
|
|
|
|
|
cache.UpsertAsync(new Counter { Name = "sequenced", Value = i }, handle);
|
|
|
|
|
})).ToArray();
|
|
|
|
|
var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
while (!writers.All(writer => writer.IsCompleted))
|
|
|
|
|
reads.Add(cache.GetWithSequence(key));
|
|
|
|
|
})).ToArray();
|
|
|
|
|
|
|
|
|
|
Assert.That(Task.WaitAll(writers.Concat(readers).ToArray(), TimeSpan.FromSeconds(60)), Is.True);
|
|
|
|
|
var ordered = changes.OrderBy(change => change.Sequence).ToArray();
|
|
|
|
|
var sequences = ordered.Select(change => change.Sequence).ToArray();
|
|
|
|
|
Assert.That(reads, Is.Not.Empty);
|
|
|
|
|
foreach (var (document, sequence) in reads)
|
|
|
|
|
{
|
|
|
|
|
var index = Array.BinarySearch(sequences, sequence);
|
|
|
|
|
Assert.That(index, Is.GreaterThanOrEqualTo(0), "a read's Sequence names no admitted change");
|
|
|
|
|
Assert.That(document, Is.SameAs(ordered[index].Document), $"read at Sequence {sequence} missed a change at or below it");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
private string PathOf(string fileName) => System.IO.Path.Combine(_directory, fileName);
|
|
|
|
|
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(Counter), typeof(Tally), typeof(Slow) });
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
private static StoreFactory SingleFile(string path, CultDocumentRegistry? registry = null) =>
|
|
|
|
|
new(path, () => new SingleFileMessagePackBackingStore(path), registry);
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
private static StoreFactory DirectoryStore(string path, CultDocumentRegistry? registry = null) =>
|
|
|
|
|
new(path, () => new DirectoryMessagePackBackingStore(path), registry);
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
|
2026-10-22 20:49:43 +02:00
|
|
|
// The id Wide is stored under, which the later versions declare compatible: a cache reads and writes a record under another
|
|
|
|
|
// id only when its type declares it. SeedDrift checks that the constant is Wide's id.
|
|
|
|
|
private const string WideDriftSchemaId = "sha256:6033aeca8a784899aff1f8c43eb9df6bf3364e4fb6014e423c65afd51a9b8f18";
|
|
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
private static readonly CultRecordKey DriftKey = new("drift");
|
|
|
|
|
private static readonly CultDocumentRegistry WideDrift = CultDocumentRegistry.ForTypes(new[] { typeof(ConditionalDriftWide) });
|
|
|
|
|
private static readonly CultDocumentRegistry NarrowDrift = CultDocumentRegistry.ForTypes(new[] { typeof(ConditionalDriftNarrow) });
|
|
|
|
|
private static readonly CultDocumentRegistry ReusedDrift = CultDocumentRegistry.ForTypes(new[] { typeof(ConditionalDriftReused) });
|
|
|
|
|
|
|
|
|
|
private StoreFactory Drift(bool directory, CultDocumentRegistry registry) =>
|
|
|
|
|
directory ? DirectoryStore(PathOf("drift.cc"), registry) : SingleFile(PathOf("drift.cc"), registry);
|
|
|
|
|
|
|
|
|
|
// The record at DriftKey, written under v1.
|
|
|
|
|
private void SeedDrift(bool directory)
|
|
|
|
|
{
|
2026-10-22 20:49:43 +02:00
|
|
|
Assert.That(WideDrift.GetRequired(typeof(ConditionalDriftWide)).SchemaId, Is.EqualTo(WideDriftSchemaId));
|
2026-10-21 21:02:57 +02:00
|
|
|
using var cache = Drift(directory, WideDrift).Open();
|
|
|
|
|
Assert.That(cache.Commit(batch => batch.Upsert(
|
|
|
|
|
new ConditionalDriftWide { Name = "drift", Value = 1, Extra = "extra" },
|
|
|
|
|
new CultRecordHandle<ConditionalDriftWide>(DriftKey))), Is.True);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool DriftCommit(CultCache cache, object observed, int value) => cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Expect(DriftKey, observed);
|
|
|
|
|
batch.Upsert(new ConditionalDriftNarrow { Name = "drift", Value = value }, new CultRecordHandle<ConditionalDriftNarrow>(DriftKey));
|
|
|
|
|
});
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
// Pauses a pull between reading the file and handing the records to the cache.
|
|
|
|
|
private sealed class PausingStore : SingleFileMessagePackBackingStore
|
|
|
|
|
{
|
|
|
|
|
public PausingStore(string path) : base(path)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ManualResetEventSlim? Pause;
|
|
|
|
|
public readonly ManualResetEventSlim Entered = new();
|
2026-09-13 19:46:18 +02:00
|
|
|
public volatile bool Paused;
|
|
|
|
|
public volatile bool PushedWhilePaused;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
|
|
|
|
|
public override void Push(CultStoredDocument entry)
|
|
|
|
|
{
|
2026-09-13 19:46:18 +02:00
|
|
|
if (Paused)
|
|
|
|
|
PushedWhilePaused = true;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
base.Push(entry);
|
|
|
|
|
}
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
|
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
|
|
|
protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data)
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
{
|
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
|
|
|
var snapshot = base.ReadStore(data);
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
if (Pause is { } pause)
|
|
|
|
|
{
|
2026-09-13 19:46:18 +02:00
|
|
|
Paused = true;
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
Entered.Set();
|
|
|
|
|
pause.Wait();
|
2026-09-13 19:46:18 +02:00
|
|
|
Paused = false;
|
Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.
An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.
Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.
Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return snapshot;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
private sealed class StoreFactory
|
|
|
|
|
{
|
|
|
|
|
private readonly Func<CacheBackingStore> _create;
|
|
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
private readonly CultDocumentRegistry _registry;
|
|
|
|
|
|
|
|
|
|
public StoreFactory(string path, Func<CacheBackingStore> create, CultDocumentRegistry? registry = null)
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
{
|
|
|
|
|
Path = path;
|
|
|
|
|
_create = create;
|
2026-10-21 21:02:57 +02:00
|
|
|
_registry = registry ?? Registry;
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Path { get; }
|
|
|
|
|
|
|
|
|
|
public CultCache Open()
|
|
|
|
|
{
|
2026-10-21 21:02:57 +02:00
|
|
|
var cache = new CultCache(_registry);
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
cache.AddBackingStore(_create());
|
|
|
|
|
return cache;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("tests.conditional_counter", "tests.conditional_counter.v1")]
|
2026-09-13 23:38:08 +02:00
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
internal sealed class Counter
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public int Value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:02:57 +02:00
|
|
|
// One schema at three versions of its type: Narrow dropped Wide's Extra, and Reused put Colour in that slot.
|
|
|
|
|
[CultDocument("tests.conditional_drift", "tests.conditional_drift.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class ConditionalDriftWide
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public int Value;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public string Extra = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-22 20:49:43 +02:00
|
|
|
[CultDocument("tests.conditional_drift", "tests.conditional_drift.v2", CompatibleSchemaIds = new[] { WideDriftSchemaId })]
|
2026-10-21 21:02:57 +02:00
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class ConditionalDriftNarrow
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public int Value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-22 20:49:43 +02:00
|
|
|
[CultDocument("tests.conditional_drift", "tests.conditional_drift.v3", CompatibleSchemaIds = new[] { WideDriftSchemaId })]
|
2026-10-21 21:02:57 +02:00
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class ConditionalDriftReused
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public int Value;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public string Colour = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
[CultDocument("tests.conditional_tally", "tests.conditional_tally.v1")]
|
2026-09-13 23:38:08 +02:00
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
internal sealed class Tally
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
}
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
|
|
|
|
|
// Loading a record with Blocks set waits on Pause, which parks a directory pull inside its page load.
|
|
|
|
|
[CultDocument("tests.conditional_slow", "tests.conditional_slow.v1")]
|
2026-09-13 23:38:08 +02:00
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
internal sealed class Slow
|
|
|
|
|
{
|
|
|
|
|
internal static volatile ManualResetEventSlim? Pause;
|
|
|
|
|
internal static readonly ManualResetEventSlim Entered = new();
|
2026-09-13 19:46:18 +02:00
|
|
|
internal static volatile bool Paused;
|
|
|
|
|
internal static volatile bool ReadWhilePaused;
|
2026-09-13 23:38:08 +02:00
|
|
|
[IgnoreMember] private bool _blocks;
|
|
|
|
|
[IgnoreMember] private string _name = string.Empty;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
2026-09-13 19:46:18 +02:00
|
|
|
public string Name
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
if (Paused)
|
|
|
|
|
ReadWhilePaused = true;
|
|
|
|
|
return _name;
|
|
|
|
|
}
|
|
|
|
|
set => _name = value;
|
|
|
|
|
}
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public bool Blocks
|
|
|
|
|
{
|
|
|
|
|
get => _blocks;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
_blocks = value;
|
|
|
|
|
if (value && Pause is { } pause)
|
|
|
|
|
{
|
2026-09-13 19:46:18 +02:00
|
|
|
Paused = true;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
Entered.Set();
|
|
|
|
|
pause.Wait();
|
2026-09-13 19:46:18 +02:00
|
|
|
Paused = false;
|
Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.
A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.
Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.
Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.
Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).
The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.
PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
|
|
|
}
|
|
|
|
|
}
|