fix(driver.sql): a post-open query failure is not a reachability verdict (Driver.Sql-005)

This commit is contained in:
Joseph Doherty
2026-08-10 08:00:51 -04:00
parent 90f3016a3e
commit ee1f1b28e8
2 changed files with 73 additions and 9 deletions
@@ -597,9 +597,11 @@ public sealed class SqlDriver
/// <list type="bullet">
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
/// host is Running.</item>
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/> /
/// <see cref="SqlStatusCodes.BadCommunicationError"/>) ⇒ Degraded; when nothing at all was Good,
/// the host is Stopped.</item>
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/>) ⇒
/// Degraded; when nothing at all answered, the host is Stopped.</item>
/// <item>Any <b>query-failure</b> Bad snapshot (<see cref="SqlStatusCodes.BadCommunicationError"/>)
/// ⇒ Degraded, but the host stays Running. See <see cref="IsQueryFailureClass"/>: that code is
/// minted only after the connection opened, so it is evidence the endpoint is up.</item>
/// <item>Bad codes that are <b>authoring</b> facts — an unresolvable RawPath, an absent row, a
/// type mismatch, a rejected definition — change nothing. A tag typo must never report the
/// database as down and send an operator to the wrong system.</item>
@@ -620,18 +622,19 @@ public sealed class SqlDriver
var good = 0;
var unreachable = 0;
var queryFailed = 0;
foreach (var snapshot in snapshots)
{
if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
else if (IsQueryFailureClass(snapshot.StatusCode)) queryFailed++;
}
if (unreachable > 0)
{
Degrade(
$"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
$"{Endpoint} on the last poll.");
if (good > 0)
$"{unreachable} of {snapshots.Count} Sql tag(s) timed out against {Endpoint} on the last poll.");
if (good > 0 || queryFailed > 0)
{
// Partial: something answered, so the endpoint is up — but the driver is not healthy.
TouchLastSuccessfulRead();
@@ -645,6 +648,21 @@ public sealed class SqlDriver
return;
}
if (queryFailed > 0)
{
// The connection opened and the server answered — with an error. Degraded (every tag in that
// group is Bad) but emphatically NOT a reachability verdict: reporting the endpoint down over a
// dropped table or a revoked SELECT points the operator at the wrong system, and the reconnect
// it would eventually provoke cannot fix a query.
Degrade(
$"{queryFailed} of {snapshots.Count} Sql tag(s) failed their query against {Endpoint} on the " +
"last poll; the connection opened, so check the table/column, the grant and any lock rather " +
"than the network.");
TouchLastSuccessfulRead();
TransitionHostTo(HostState.Running);
return;
}
if (good == 0) return; // authoring-class Bad only — not a statement about the database.
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
@@ -655,9 +673,28 @@ public sealed class SqlDriver
TransitionHostTo(HostState.Running);
}
/// <summary>True for the status codes that mean "the database did not answer", as opposed to "the data is not there".</summary>
private static bool IsConnectionClass(uint statusCode)
=> statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
/// <summary>
/// True for the status codes that mean "the database did not answer", as opposed to "the data is not
/// there" or "the query was refused".
/// <para><see cref="SqlStatusCodes.BadCommunicationError"/> is deliberately <b>not</b> here despite
/// its name (Driver.Sql-005): the reader mints it from <c>GroupResult.Failed</c>, which is reachable
/// only after <c>OpenAsync</c> returned. Treating it as "did not answer" made one group failing on a
/// schema change, a lock or a revoked grant report the whole endpoint down. See
/// <see cref="IsQueryFailureClass"/>.</para>
/// </summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code means the database did not answer at all.</returns>
private static bool IsConnectionClass(uint statusCode) => statusCode is SqlStatusCodes.BadTimeout;
/// <summary>
/// True for the status codes that mean "the connection opened but the query failed" — a schema
/// change, a lock, a missing grant, a statement the provider rejected. Degrading, but proof the
/// endpoint is up.
/// </summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code means the server answered the connection and refused the query.</returns>
private static bool IsQueryFailureClass(uint statusCode)
=> statusCode is SqlStatusCodes.BadCommunicationError;
/// <summary>
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
@@ -224,6 +224,33 @@ public sealed class SqlDriverTests
}
}
[Fact]
public async Task ReadAsync_whenTheQueryFailsAfterTheConnectionOpened_degradesButLeavesTheEndpointUp()
{
// Driver.Sql-005. BadCommunicationError is minted by GroupResult.Failed, which the reader raises only
// AFTER the connection opened ("the connection opened, so the server is up — this query is what
// failed"). Classifying it as connection-class reported the whole database endpoint Stopped over one
// group failing on a dropped table, a revoked SELECT or an over-long IN list, sending the operator at
// the network instead of at the schema. The driver must still degrade — every tag in that group is
// Bad — but the endpoint is demonstrably up.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
// The schema drifts out from under a driver whose catalog gate already passed.
fixture.Execute($"DROP TABLE {SqlitePollFixture.KeyValueTable}");
var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNull();
health.LastError.ShouldContain("quer"); // names the query, not the network
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); // RED pre-fix: Stopped
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand()
{