fix(driver.sql): defer HandlePollError on the exception ReadAsync classified, not on its type (Driver.Sql-004)
This commit is contained in:
@@ -391,23 +391,91 @@ public sealed class SqlDriverTests
|
||||
{
|
||||
// On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a
|
||||
// good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll
|
||||
// engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync
|
||||
// owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a
|
||||
// credential-leak path) and logged the outage a second time.
|
||||
// engine then routes to HandlePollError. HandlePollError must leave the classification ReadAsync
|
||||
// already made alone — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two
|
||||
// LastErrors, and a credential-leak path) and logged the outage a second time.
|
||||
// Driven through the REAL round trip rather than by handing HandlePollError a bare exception: the
|
||||
// deferral is owned by ReadAsync marking the throw, not by the exception's type (Driver.Sql-004).
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new CapturingLogger();
|
||||
await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
var factory = new BreakableFactory(fixture.Factory);
|
||||
await using var driver = NewDriver(
|
||||
fixture, fixture.ConnectionString, logger, factory, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
factory.BreakByThrowingOnOpen("connection failed; Password=" + SecretToken);
|
||||
var thrown = await Should.ThrowAsync<DbException>(
|
||||
async () => await driver.ReadAsync(["Speed"], CancellationToken.None));
|
||||
|
||||
var classified = driver.GetHealth();
|
||||
classified.State.ShouldBe(DriverState.Degraded);
|
||||
classified.LastError.ShouldNotBeNull();
|
||||
classified.LastError.ShouldContain("could not reach");
|
||||
var warningsAfterRead = logger.Entries.Count(e => e.Level == LogLevel.Warning);
|
||||
|
||||
driver.HandlePollError(thrown);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
// Untouched: HandlePollError left the exception to ReadAsync, so it neither re-degraded nor leaked…
|
||||
health.LastError.ShouldBe(classified.LastError);
|
||||
health.LastError.ShouldNotContain(SecretToken);
|
||||
// …and it logged nothing (ReadAsync owns the single outage warning).
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(warningsAfterRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePollError_forANonDbExceptionReadAsyncClassified_stillDefers_soTheGoodLastErrorSurvives()
|
||||
{
|
||||
// Driver.Sql-004. Not every unreachable-database throw is a DbException: the reader raises a plain
|
||||
// InvalidOperationException when the provider factory hands back no connection, and a provider can
|
||||
// raise ArgumentException/FormatException off a malformed connection string on the same open path.
|
||||
// ReadAsync classifies those exactly as it classifies a DbException — endpoint-bearing LastError,
|
||||
// host Stopped — and rethrows, so HandlePollError must defer to it here too. RED against the
|
||||
// `ex is DbException` type test, which re-degraded with the guidance-free "poll failed: TypeName"
|
||||
// and logged the outage twice.
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new CapturingLogger();
|
||||
var factory = new BreakableFactory(fixture.Factory);
|
||||
await using var driver = NewDriver(
|
||||
fixture, fixture.ConnectionString, logger, factory, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
factory.BreakByReturningNoConnection();
|
||||
var thrown = await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await driver.ReadAsync(["Speed"], CancellationToken.None));
|
||||
|
||||
var classified = driver.GetHealth();
|
||||
classified.State.ShouldBe(DriverState.Degraded);
|
||||
classified.LastError.ShouldNotBeNull();
|
||||
classified.LastError.ShouldContain("could not reach");
|
||||
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
|
||||
var warningsAfterRead = logger.Entries.Count(e => e.Level == LogLevel.Warning);
|
||||
|
||||
driver.HandlePollError(thrown);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.LastError.ShouldBe(classified.LastError); // the richer message survived…
|
||||
health.LastError.ShouldNotContain("poll failed"); // …and was not overwritten by the poor one
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(warningsAfterRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePollError_forAnExceptionReadAsyncNeverSaw_degrades_evenWhenItIsADbException()
|
||||
{
|
||||
// The falsifiability control for the two deferrals above: the marker is per-exception, not per-type,
|
||||
// so a DbException that never went through ReadAsync's classification is NOT owned by it and must
|
||||
// still reach the health surface. A type test would silently swallow this one.
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
|
||||
driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken));
|
||||
|
||||
var health = driver.GetHealth();
|
||||
// Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked…
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastError.ShouldBeNull();
|
||||
// …and it logged nothing (ReadAsync owns the single outage warning).
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0);
|
||||
health.State.ShouldBe(DriverState.Degraded);
|
||||
health.LastError.ShouldNotBeNull();
|
||||
health.LastError.ShouldNotContain(SecretToken); // type only, never the message text
|
||||
health.LastError.ShouldContain(nameof(FakeDbException));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -518,6 +586,26 @@ public sealed class SqlDriverTests
|
||||
operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10),
|
||||
rawTags: rawTags);
|
||||
|
||||
/// <summary>The same driver, over a caller-supplied provider factory — the outage-injection seam.</summary>
|
||||
private static SqlDriver NewDriver(
|
||||
SqlitePollFixture fixture,
|
||||
string connectionString,
|
||||
CapturingLogger logger,
|
||||
DbProviderFactory factory,
|
||||
params RawTagEntry[] rawTags)
|
||||
=> new(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = rawTags,
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new SqliteDialect(),
|
||||
connectionString,
|
||||
factory: factory,
|
||||
logger: logger);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the driver through its production constructor. That constructor <b>is</b> the injection
|
||||
/// seam — dialect, provider factory and already-resolved connection string are all parameters — so no
|
||||
@@ -668,6 +756,28 @@ public sealed class SqlDriverTests
|
||||
public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A provider factory that behaves exactly like the fixture's until it is broken, then reproduces one
|
||||
/// of the two open-path outages the driver has to classify: a factory that returns <b>no</b>
|
||||
/// connection (a plain <see cref="InvalidOperationException"/> from the reader — NOT a
|
||||
/// <see cref="DbException"/>), or one whose connections throw on open.
|
||||
/// </summary>
|
||||
private sealed class BreakableFactory(DbProviderFactory inner) : DbProviderFactory
|
||||
{
|
||||
private Func<DbConnection?>? _broken;
|
||||
|
||||
/// <summary>Hands back no connection, the shape that makes the reader raise InvalidOperationException.</summary>
|
||||
public void BreakByReturningNoConnection() => Volatile.Write(ref _broken, static () => null);
|
||||
|
||||
/// <summary>Hands back a connection that fails to open with <paramref name="message"/>.</summary>
|
||||
/// <param name="message">The provider message, which may carry credentials.</param>
|
||||
public void BreakByThrowingOnOpen(string message)
|
||||
=> Volatile.Write(ref _broken, () => new ThrowingConnection(message));
|
||||
|
||||
public override DbConnection? CreateConnection()
|
||||
=> Volatile.Read(ref _broken) is { } broken ? broken() : inner.CreateConnection();
|
||||
}
|
||||
|
||||
/// <summary>A connection that throws <see cref="FakeDbException"/> on open; every other member is inert.</summary>
|
||||
private sealed class ThrowingConnection(string openFailureMessage) : DbConnection
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user