fix(sql): review minors - superseded-reader polls, rebind faults, disposed-reader guard, JSON position (R06 G2 review m-1 m-2 m-4 m-6 m-7)

m-1: a poll that captured a reader a rebind then disposed retries once on the current
reader; with no newer reader the typed SqlPollReaderDisposedException is marked
classified, so HandlePollError no longer degrades for it. Groups report a disposed
reader as a sentinel so WhenAll surfaces the typed exception, not a bare cancel.
m-2: TryAddRef refuses new groups once Dispose ran, matching the doc comment.
m-4: a rebind / tag-table failure in InitializeAsync now writes Faulted with a
sanitized reason (generation-checked) and host Stopped instead of latching
Initializing with no LastError.
m-6: SecurityClass-as-descriptor is documented at the Sql site (Commons field is a
follow-up). m-7: a position-less JsonException renders '?' instead of blanks.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:35:52 -04:00
parent c1b9c7ec5c
commit 099f253321
6 changed files with 137 additions and 10 deletions
@@ -484,6 +484,64 @@ public sealed class SqlDriverTests
skipped.Reason.ShouldContain("Query");
}
/// <summary>
/// <b>Review m-1.</b> A redeploy re-initialises without teardown, so a poll can capture the reader just
/// before a rebind disposes it. That poll used to throw an OperationCanceledException the engine then
/// classified as a poll failure (Degrade). It now retries once on the current reader — and reads Good,
/// the positive control — with health untouched.
/// </summary>
[Fact]
public async Task APollOnAReplacedReader_retriesOnTheCurrentOne()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var captured = driver.ReaderForTests;
driver.RebindForTests();
captured.GateDisposedForTests.ShouldBeTrue();
var snapshots = await driver.ReadThroughAsync(
captured, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// With no newer reader (the driver's own reader was disposed) the disposed-reader exception still
/// propagates, but the engine's error callback must not degrade for it. Control: any other poll error
/// still degrades.
/// </summary>
[Fact]
public async Task ADisposedReaderError_isNotAPollFailure()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.ReaderForTests.Dispose();
// Awaited directly (as the poll engine does): an OperationCanceledException-derived throw ends the task
// Canceled, and only a direct await rethrows the original instance — which HandlePollError matches on.
Exception? caught = null;
try
{
await driver.ReadThroughAsync(
driver.ReaderForTests, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
}
catch (Exception e)
{
caught = e;
}
caught.ShouldBeOfType<SqlPollReaderDisposedException>();
driver.HandlePollError(caught!);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.HandlePollError(new InvalidOperationException("contract violation"));
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
}
// ---- Driver.Sql-010: the health field is read-modify-written from several poll loops ----
[Fact]
@@ -81,6 +81,11 @@ public sealed class SqlReinitConfigAdoptionTests
"old-server",
customMessage:
"a reinitialize that could not resolve its new connection must leave the previous binding whole");
// Review m-4: the failure is a fault with a reason, never a latched Initializing with no LastError.
var health = driver.GetHealth();
health.State.ShouldBe(Core.Abstractions.DriverState.Faulted);
health.LastError.ShouldNotBeNull().ShouldContain("could not adopt its configuration");
}
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle