From ee1f1b28e864f4572300874587200f49a302f764 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 08:00:51 -0400 Subject: [PATCH] fix(driver.sql): a post-open query failure is not a reachability verdict (Driver.Sql-005) --- .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 55 ++++++++++++++++--- .../SqlDriverTests.cs | 27 +++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index b3eae57d..d381e0e0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -597,9 +597,11 @@ public sealed class SqlDriver /// /// Any Good snapshot ⇒ the database answered: LastSuccessfulRead advances and the /// host is Running. - /// Any connection-class Bad snapshot ( / - /// ) ⇒ Degraded; when nothing at all was Good, - /// the host is Stopped. + /// Any connection-class Bad snapshot () ⇒ + /// Degraded; when nothing at all answered, the host is Stopped. + /// Any query-failure Bad snapshot () + /// ⇒ Degraded, but the host stays Running. See : that code is + /// minted only after the connection opened, so it is evidence the endpoint is up. /// Bad codes that are authoring 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. @@ -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); } - /// True for the status codes that mean "the database did not answer", as opposed to "the data is not there". - private static bool IsConnectionClass(uint statusCode) - => statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError; + /// + /// 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". + /// is deliberately not here despite + /// its name (Driver.Sql-005): the reader mints it from GroupResult.Failed, which is reachable + /// only after OpenAsync 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 + /// . + /// + /// The status code to classify. + /// True when the code means the database did not answer at all. + private static bool IsConnectionClass(uint statusCode) => statusCode is SqlStatusCodes.BadTimeout; + + /// + /// 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. + /// + /// The status code to classify. + /// True when the code means the server answered the connection and refused the query. + private static bool IsQueryFailureClass(uint statusCode) + => statusCode is SqlStatusCodes.BadCommunicationError; /// /// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does not diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs index 1a52bd2c..68ac4fc6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs @@ -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() {