fix(driver.sql): advance LastSuccessfulRead on evidence the database answered (Driver.Sql-006)
This commit is contained in:
@@ -595,8 +595,11 @@ public sealed class SqlDriver
|
||||
/// <summary>
|
||||
/// Classifies one poll's snapshots into a health verdict and a host state.
|
||||
/// <list type="bullet">
|
||||
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
|
||||
/// host is Running.</item>
|
||||
/// <item>Any Good snapshot ⇒ Healthy and the host is Running.</item>
|
||||
/// <item>Any snapshot that is <b>evidence the database answered</b>
|
||||
/// (<see cref="IsAnswerEvidence"/> — Good, but also an absent row, a NULL cell, an uncoercible
|
||||
/// cell) ⇒ <c>LastSuccessfulRead</c> advances and the host is Running, <em>without</em> changing
|
||||
/// the health state. That clock is what the comms-loss watchdog reads (Driver.Sql-006).</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"/>)
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// True for the status codes that are proof the database <b>answered</b> this poll — the evidence
|
||||
/// <c>LastSuccessfulRead</c> records, which is not the same question as "is this value good".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Driver.Sql-006. <c>DriverInstanceActor</c>'s comms-loss watchdog force-reconnects a driver
|
||||
/// that is non-Healthy <b>and</b> whose <c>LastSuccessfulRead</c> 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.</para>
|
||||
/// <para>What is deliberately excluded is as load-bearing as what is included:
|
||||
/// <see cref="SqlStatusCodes.BadTimeout"/> is the database <em>not</em> answering, and
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> / <see cref="SqlStatusCodes.BadConfigurationError"/>
|
||||
/// / <see cref="SqlStatusCodes.BadInternalError"/> 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.</para>
|
||||
/// </remarks>
|
||||
/// <param name="statusCode">The status code to classify.</param>
|
||||
/// <returns>True when the code could only have been produced by a database that answered.</returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
|
||||
/// touch host state: the engine also reports its own contract violations here, which say nothing
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user