fix(driver.sql): compare-and-swap the health field so a concurrent transition cannot be lost (Driver.Sql-010)

This commit is contained in:
Joseph Doherty
2026-08-10 08:05:23 -04:00
parent a0a49d3395
commit 336e79ea9a
2 changed files with 102 additions and 19 deletions
@@ -356,6 +356,55 @@ public sealed class SqlDriverTests
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
}
// ---- Driver.Sql-010: the health field is read-modify-written from several poll loops ----
[Fact]
public async Task HealthTransitions_fromConcurrentPolls_neverRewindTheEvidenceClock()
{
// Driver.Sql-010. Every health transition is a read-modify-write of one field: Degrade reads the
// current snapshot to PRESERVE LastSuccessfulRead, then writes a new record. With a plain
// Volatile read + write, a Degrade that read the clock at t0 can be published after a concurrent
// advance to t1 — so the clock goes BACKWARDS, and the advance is lost. That is not cosmetic: the
// comms-loss watchdog measures how long the clock has been frozen, so a lost advance is a spurious
// 90 s reconnect. Monotonicity is the observable the lost update violates, and it is exactly what a
// compare-and-swap guarantees.
//
// A driver with several subscriptions runs one poll loop each, plus any ad-hoc ReadAsync — so two
// classifications landing at once is the ordinary case, not a contrived one.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
const int iterations = 30_000;
var advancing = new DataValueSnapshot(1.0, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow);
var degrading = new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, DateTime.UtcNow, DateTime.UtcNow);
using var done = new CancellationTokenSource();
var rewinds = 0;
var observer = Task.Run(() =>
{
var highWater = driver.GetHealth().LastSuccessfulRead ?? DateTime.MinValue;
while (!done.IsCancellationRequested)
{
var seen = driver.GetHealth().LastSuccessfulRead ?? DateTime.MinValue;
if (seen < highWater) Interlocked.Increment(ref rewinds);
else highWater = seen;
}
});
var writers = new[]
{
Task.Run(() => { for (var i = 0; i < iterations; i++) driver.ObservePollOutcome([advancing]); }),
Task.Run(() => { for (var i = 0; i < iterations; i++) driver.ObservePollOutcome([degrading]); }),
};
await Task.WhenAll(writers);
await done.CancelAsync();
await observer;
Volatile.Read(ref rewinds).ShouldBe(0);
}
// ---- ISubscribable (poll-engine overlay) ----
[Fact]