merge: SQL driver health review findings resolved (Driver.Sql-004/-005/-006/-008/-010)
# Conflicts: # docs/drivers/Sql.md
This commit is contained in:
@@ -155,6 +155,20 @@ public sealed class SqlDriver
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// The exception instance <see cref="ReadAsync"/> has already turned into a health verdict, held only
|
||||
/// until <see cref="HandlePollError"/> — which the poll engine calls with that same object — has
|
||||
/// consumed it (Driver.Sql-004).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A field rather than a flag on the exception itself: <see cref="Exception.Data"/> is provider-owned
|
||||
/// and may be read-only, and wrapping the throw would change the type the engine and
|
||||
/// <see cref="IReadable"/>'s callers see. Concurrent poll loops can overwrite each other's mark; the
|
||||
/// loser is then merely re-degraded with a poorer message, which is the pre-fix behaviour rather than
|
||||
/// a new failure.
|
||||
/// </remarks>
|
||||
private Exception? _classifiedByRead;
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
@@ -528,6 +542,10 @@ public sealed class SqlDriver
|
||||
// on the log sink via the LogWarning exception parameter above.
|
||||
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
// Mark THIS exception object as already classified before it goes back to the poll engine, which
|
||||
// routes it to HandlePollError next. The mark is what makes the deferral there exact — see that
|
||||
// method for why inferring it from the exception's type was not (Driver.Sql-004).
|
||||
Volatile.Write(ref _classifiedByRead, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -577,11 +595,16 @@ 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 <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 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"/>)
|
||||
/// ⇒ 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>
|
||||
@@ -602,18 +625,22 @@ 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 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 (answered > 0)
|
||||
{
|
||||
// Partial: something answered, so the endpoint is up — but the driver is not healthy.
|
||||
TouchLastSuccessfulRead();
|
||||
@@ -627,19 +654,92 @@ public sealed class SqlDriver
|
||||
return;
|
||||
}
|
||||
|
||||
if (good == 0) return; // authoring-class Bad only — not a statement about the database.
|
||||
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 (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
|
||||
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
|
||||
// to Healthy with no real recovery. Only a successful (re)initialize clears Faulted.
|
||||
SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
RecordHealthyRead();
|
||||
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>
|
||||
/// 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>
|
||||
@@ -650,11 +750,20 @@ public sealed class SqlDriver
|
||||
/// <param name="ex">The exception the poll engine caught.</param>
|
||||
internal void HandlePollError(Exception ex)
|
||||
{
|
||||
// A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
|
||||
// classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
|
||||
// then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
|
||||
// guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns.
|
||||
if (ex is DbException) return;
|
||||
// A subscribed-read outage is classified by ReadAsync — good, endpoint-bearing LastError + host
|
||||
// Stopped — before it rethrows, and the poll engine then routes that SAME exception object here.
|
||||
// Re-degrading would overwrite the good LastError with a worse, guidance-free one and log the outage
|
||||
// a second time, so defer to the classification that already happened.
|
||||
//
|
||||
// Matched on the exception INSTANCE ReadAsync marked, not on its type. `ex is DbException` was the
|
||||
// stated intent — "the class ReadAsync owns" — but not what it implemented (Driver.Sql-004): the
|
||||
// reader raises a plain InvalidOperationException when the provider factory returns no connection,
|
||||
// and a provider can raise ArgumentException/FormatException off a malformed connection string on
|
||||
// the same open path. Those are classified by ReadAsync exactly like a DbException, yet a type test
|
||||
// let them through and clobbered its message; conversely a DbException that never went through
|
||||
// ReadAsync — the poll engine's own throw — was swallowed. Consumed once, so a later unclassified
|
||||
// exception is never mistaken for this one.
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _classifiedByRead, null, ex), ex)) return;
|
||||
|
||||
// Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised
|
||||
// (e.g. an ArgumentException from a malformed connection string, which the parser echoes with
|
||||
@@ -673,36 +782,70 @@ public sealed class SqlDriver
|
||||
/// </summary>
|
||||
/// <param name="reason">The operator-facing reason, which must never carry the connection string.</param>
|
||||
private void Degrade(string reason)
|
||||
{
|
||||
var current = ReadHealth();
|
||||
SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
|
||||
}
|
||||
=> UpdateHealthUnlessFaulted(
|
||||
current => new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
|
||||
|
||||
/// <summary>
|
||||
/// Publishes <paramref name="value"/> unless the driver is already <see cref="DriverState.Faulted"/>
|
||||
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
|
||||
/// health write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
|
||||
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
|
||||
/// <see cref="ReinitializeAsync"/> just faulted.
|
||||
/// Records a poll that returned at least one Good value: Healthy, with the evidence clock advanced.
|
||||
/// <para>The timestamp is taken <b>inside</b> the transform, so a retried compare-and-swap publishes
|
||||
/// a fresh one rather than the one this call started with — which a concurrent advance could already
|
||||
/// have overtaken.</para>
|
||||
/// </summary>
|
||||
/// <param name="value">The health snapshot to publish when the driver is not Faulted.</param>
|
||||
private void SetHealthUnlessFaulted(DriverHealth value)
|
||||
{
|
||||
if (ReadHealth().State == DriverState.Faulted) return;
|
||||
WriteHealth(value);
|
||||
}
|
||||
private void RecordHealthyRead()
|
||||
=> UpdateHealthUnlessFaulted(
|
||||
static _ => new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
|
||||
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the current state or error.</summary>
|
||||
private void TouchLastSuccessfulRead()
|
||||
=> UpdateHealth(static current => current with { LastSuccessfulRead = DateTime.UtcNow });
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="project"/> unless the driver is already <see cref="DriverState.Faulted"/>
|
||||
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
|
||||
/// state write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
|
||||
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
|
||||
/// <see cref="ReinitializeAsync"/> just faulted. The guard is evaluated against the <em>same</em>
|
||||
/// snapshot the write then swaps out, so a Faulted verdict landing mid-update cannot be lost.
|
||||
/// </summary>
|
||||
/// <param name="project">Builds the snapshot to publish from the current one.</param>
|
||||
private void UpdateHealthUnlessFaulted(Func<DriverHealth, DriverHealth> project)
|
||||
=> UpdateHealth(current => current.State == DriverState.Faulted ? null : project(current));
|
||||
|
||||
/// <summary>
|
||||
/// The one read-modify-write of the health field, as a compare-and-swap retry loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Driver.Sql-010. Health is transitioned from every poll loop (one per subscription) plus any
|
||||
/// ad-hoc <see cref="ReadAsync"/>, and every transition needs the current snapshot to build the next
|
||||
/// one — <see cref="Degrade"/> preserves <c>LastSuccessfulRead</c>, the Faulted guard reads the state.
|
||||
/// A plain <c>Volatile</c> read followed by a <c>Volatile</c> write lets a concurrent update land in
|
||||
/// between and be silently overwritten: an advance of the evidence clock the comms-loss watchdog
|
||||
/// reads is lost (and the clock even moves backwards), or a Faulted verdict is downgraded despite the
|
||||
/// guard. <paramref name="transform"/> is re-run on each attempt, so it must be side-effect-free and
|
||||
/// must derive everything — timestamps included — from the snapshot it is handed or from the clock.
|
||||
/// Returning null declines the update.
|
||||
/// </remarks>
|
||||
/// <param name="transform">Builds the snapshot to publish, or returns null to leave health untouched.</param>
|
||||
private void UpdateHealth(Func<DriverHealth, DriverHealth?> transform)
|
||||
{
|
||||
var current = ReadHealth();
|
||||
WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref _health);
|
||||
var next = transform(current);
|
||||
if (next is null) return;
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
|
||||
/// <summary>
|
||||
/// Unconditional publish of a new health snapshot, for the lifecycle transitions that are
|
||||
/// last-writer-wins by definition (Initialize / Shutdown). Everything driven by a poll goes through
|
||||
/// <see cref="UpdateHealth"/> instead, because it has to read the current snapshot first.
|
||||
/// </summary>
|
||||
/// <param name="value">The health snapshot to publish.</param>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
|
||||
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
|
||||
|
||||
@@ -566,9 +566,19 @@ public sealed class SqlPollReader
|
||||
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
|
||||
/// (<c>Rows.Count > 1</c>), converting a loud misconfiguration into a permanently silent one —
|
||||
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
|
||||
/// form keeps its row limit because there the <c>ORDER BY</c> makes "the first row" a defined
|
||||
/// answer. The accepted cost is that an ambiguous selector materialises every matching row for one
|
||||
/// form keeps its row limit because there the <c>ORDER BY</c> at least fixes <em>which</em> rows can
|
||||
/// be first. The accepted cost is that an ambiguous selector materialises every matching row for one
|
||||
/// poll; the warning is what gets it fixed.</para>
|
||||
/// <para><b>⚠ The <c>topByTimestamp</c> answer is only defined when the ordering column is unique</b>
|
||||
/// (Driver.Sql-008). <c>ORDER BY ts DESC</c> plus a one-row limit over a column with ties returns an
|
||||
/// arbitrary row among the tied ones, and — unlike the where-pair form — this method can never
|
||||
/// detect it, because the server discarded the other rows before the result set was built: there is
|
||||
/// no <c>Rows.Count > 1</c> left to warn about. The tie shape is reachable rather than
|
||||
/// theoretical, since <c>SqlBrowseSession.PreferredTimestampColumn</c> will nominate a
|
||||
/// <c>date</c>-typed column, which ties for every row written on the same day. The model's premise is
|
||||
/// unchanged — the selector must identify one row — but for this form the operator, not the driver,
|
||||
/// is the only thing that can check it. Point the tag at a column that is unique per row (or at a
|
||||
/// latest-row view).</para>
|
||||
/// </summary>
|
||||
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user