fix(driver.sql): defer HandlePollError on the exception ReadAsync classified, not on its type (Driver.Sql-004)

This commit is contained in:
Joseph Doherty
2026-08-10 07:59:09 -04:00
parent 94b8633e68
commit 90f3016a3e
2 changed files with 151 additions and 14 deletions
@@ -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;
}
}
@@ -650,11 +668,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