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 d381e0e0..935f337f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -595,8 +595,11 @@ public sealed class SqlDriver /// /// Classifies one poll's snapshots into a health verdict and a host state. /// - /// Any Good snapshot ⇒ the database answered: LastSuccessfulRead advances and the - /// host is Running. + /// Any Good snapshot ⇒ Healthy and the host is Running. + /// Any snapshot that is evidence the database answered + /// ( — Good, but also an absent row, a NULL cell, an uncoercible + /// cell) ⇒ LastSuccessfulRead advances and the host is Running, without changing + /// the health state. That clock is what the comms-loss watchdog reads (Driver.Sql-006). /// Any connection-class Bad snapshot () ⇒ /// Degraded; when nothing at all answered, the host is Stopped. /// Any query-failure Bad snapshot () @@ -623,18 +626,21 @@ public sealed class SqlDriver var good = 0; var unreachable = 0; var queryFailed = 0; + var answered = 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 (IsAnswerEvidence(snapshot.StatusCode)) answered++; } if (unreachable > 0) { Degrade( $"{unreachable} of {snapshots.Count} Sql tag(s) timed out against {Endpoint} on the last poll."); - if (good > 0 || queryFailed > 0) + if (answered > 0) { // Partial: something answered, so the endpoint is up — but the driver is not healthy. TouchLastSuccessfulRead(); @@ -663,7 +669,18 @@ public sealed class SqlDriver return; } - if (good == 0) return; // authoring-class Bad only — not a statement about the database. + if (answered == 0) return; // authoring-class Bad only — not a statement about the database. + + if (good == 0) + { + // The database answered, just not with a value: an absent row, a NULL cell, a cell that would + // not coerce. Those are data/authoring facts, so the health STATE is deliberately left exactly + // as it was — but they are still proof the transport works, and the evidence clock the + // comms-loss watchdog reads must say so (Driver.Sql-006). + TouchLastSuccessfulRead(); + TransitionHostTo(HostState.Running); + return; + } // Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent // ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s @@ -696,6 +713,34 @@ public sealed class SqlDriver private static bool IsQueryFailureClass(uint statusCode) => statusCode is SqlStatusCodes.BadCommunicationError; + /// + /// True for the status codes that are proof the database answered this poll — the evidence + /// LastSuccessfulRead records, which is not the same question as "is this value good". + /// + /// + /// Driver.Sql-006. DriverInstanceActor's comms-loss watchdog force-reconnects a driver + /// that is non-Healthy and whose LastSuccessfulRead has been frozen for 90 s (Gitea + /// #527), on the stated premise that the clock is the honest evidence the transport works. Advancing + /// it on Good alone made a truncated staging table or a deleted key set look — to that watchdog — + /// exactly like a dead socket, so a database answering every poll on time got its connection torn + /// down and rebuilt every 90 s, forever. + /// What is deliberately excluded is as load-bearing as what is included: + /// is the database not answering, and + /// / + /// / are minted before any statement is executed. A + /// blanket "anything that is not a timeout" would keep the clock fresh on a driver that never once + /// reached the database, which disables the watchdog rather than fixing it. + /// + /// The status code to classify. + /// True when the code could only have been produced by a database that answered. + private static bool IsAnswerEvidence(uint statusCode) => statusCode is + SqlStatusCodes.Good // the row is there and readable + or SqlStatusCodes.BadNoData // the query ran; that row is absent + or SqlStatusCodes.Uncertain // a present row with a NULL cell (nullIsBad=false) + or SqlStatusCodes.Bad // …the same row under nullIsBad=true + or SqlStatusCodes.BadTypeMismatch // a cell came back and would not coerce + or SqlStatusCodes.BadCommunicationError; // the connection opened; the server refused the query + /// /// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does not /// touch host state: the engine also reports its own contract violations here, which say nothing 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 68ac4fc6..2711da6b 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 @@ -269,6 +269,93 @@ public sealed class SqlDriverTests driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); } + // ---- the comms-loss watchdog's evidence clock (Driver.Sql-006) ---- + + [Fact] + public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_withoutChangingHealth() + { + // Driver.Sql-006. LastSuccessfulRead is what DriverInstanceActor's comms-loss watchdog reads: a + // non-Healthy driver whose clock has been frozen for 90 s is force-reconnected (Gitea #527), on the + // premise that the clock is the honest evidence the transport works. BadNoData IS that evidence — + // the query ran and the row is absent — so a driver whose key set was deleted must not be + // reconnected every 90 s against a database answering every poll on time. The health STATE stays + // exactly as it was: an absent row is a data fact, not a connectivity one. + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Missing", SqlitePollFixture.AbsentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + // Something has degraded the driver — the watchdog's first conjunct. Only then does the frozen clock + // matter, which is why the scenario needs both. + driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots")); + var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + await Task.Delay(20, TestContext.Current.CancellationToken); + + var snapshots = await driver.ReadAsync(["Missing"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + var health = driver.GetHealth(); + health.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); // RED pre-fix: frozen + health.State.ShouldBe(DriverState.Degraded); // classification untouched + } + + [Fact] + public async Task ReadAsync_anUnresolvableRef_isNoEvidenceTheDatabaseAnswered_soTheEvidenceClockStands() + { + // The falsifiability control: "advance on anything that is not Good" would pass the test above and + // break the watchdog outright. BadNodeIdUnknown is minted by the resolver without any database + // contact at all, so it proves nothing about the transport and must leave the clock alone — + // otherwise a driver polling only typoed refs would look forever alive to the watchdog. + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + await Task.Delay(20, TestContext.Current.CancellationToken); + + var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + driver.GetHealth().LastSuccessfulRead.ShouldBe(before); + } + + [Theory] + [InlineData(SqlStatusCodes.Good)] // the row is there and readable + [InlineData(SqlStatusCodes.BadNoData)] // the query ran; the row is absent + [InlineData(SqlStatusCodes.Uncertain)] // a present row with a NULL cell (nullIsBad=false) + [InlineData(SqlStatusCodes.Bad)] // …the same row under nullIsBad=true + [InlineData(SqlStatusCodes.BadTypeMismatch)] // a cell came back and would not coerce + [InlineData(SqlStatusCodes.BadCommunicationError)] // the connection opened; the query was refused + public async Task ObservePollOutcome_everyEvidenceOfAnswerCode_advancesTheEvidenceClock(uint statusCode) + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + await Task.Delay(20, TestContext.Current.CancellationToken); + + driver.ObservePollOutcome([new DataValueSnapshot(null, statusCode, DateTime.UtcNow, DateTime.UtcNow)]); + + driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); + } + + [Theory] + [InlineData(SqlStatusCodes.BadTimeout)] // the database did not answer at all + [InlineData(SqlStatusCodes.BadNodeIdUnknown)] // the resolver never reached the database + [InlineData(SqlStatusCodes.BadConfigurationError)] // the planner rejected the tag; nothing was executed + [InlineData(SqlStatusCodes.BadInternalError)] // a reader defect; no statement about the database + public async Task ObservePollOutcome_aCodeThatIsNoEvidenceOfAnAnswer_leavesTheEvidenceClockAlone( + uint statusCode) + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + await Task.Delay(20, TestContext.Current.CancellationToken); + + driver.ObservePollOutcome([new DataValueSnapshot(null, statusCode, DateTime.UtcNow, DateTime.UtcNow)]); + + driver.GetHealth().LastSuccessfulRead.ShouldBe(before); + } + // ---- ISubscribable (poll-engine overlay) ---- [Fact]