fix(db): classify non-SqlException DB outages as transient; propagate cancellation (#7)

ExecuteWriteAsync only caught SqlException, so a live outage surfacing as
InvalidOperationException/SocketException/IOException/TimeoutException escaped
unclassified and crashed the script actor instead of buffering. Mirror the HTTP
path: propagate OperationCanceledException on cancellation, classify transport
exceptions as transient (buffer+retry), let unexpected exceptions propagate.
This commit is contained in:
Joseph Doherty
2026-06-15 14:03:25 -04:00
parent d05270640d
commit de375ff7ea
4 changed files with 335 additions and 21 deletions
@@ -281,15 +281,21 @@ public class DatabaseGateway : IDatabaseGateway
/// <summary>
/// M2.3 (#7): executes a parameterised SQL write against the given connection
/// string and classifies any <see cref="SqlException"/> into
/// <see cref="TransientDatabaseException"/> / <see cref="PermanentDatabaseException"/>
/// via <see cref="SqlErrorClassifier"/>. This is the single SQL-execution seam
/// shared by the immediate <see cref="CachedWriteAsync"/> attempt and the
/// string and classifies the outcome into
/// <see cref="TransientDatabaseException"/> / <see cref="PermanentDatabaseException"/>,
/// mirroring the ordered catches of
/// <see cref="ExternalSystemClient.InvokeHttpAsync"/> on the API path:
/// caller-requested cancellation propagates unchanged; a <see cref="SqlException"/>
/// is classified by error number via <see cref="SqlErrorClassifier"/>; a
/// non-<see cref="SqlException"/> transport/connection outage is classified
/// transient via <see cref="SqlErrorClassifier.IsTransient(System.Exception)"/>;
/// genuinely-unexpected exceptions propagate. This is the single classification
/// seam shared by the immediate <see cref="CachedWriteAsync"/> attempt and the
/// <see cref="DeliverBufferedAsync"/> retry path. Marked <c>internal virtual</c>
/// so tests can substitute success / transient / permanent outcomes without a
/// real SQL Server (and without fabricating a <see cref="SqlException"/>, which
/// has no public constructor). Mirrors the role of
/// <see cref="ExternalSystemClient.InvokeHttpAsync"/> on the API path.
/// so tests can substitute already-classified outcomes; the raw I/O lives in
/// the inner <see cref="RunSqlAsync"/> seam so tests can also drive raw outage
/// exceptions through this classification (without fabricating a
/// <see cref="SqlException"/>, which has no public constructor).
/// </summary>
/// <param name="connectionName">The human-readable connection name, used only for the classified error message (never the connection string — that would leak credentials into logs / script-visible errors).</param>
/// <param name="connectionString">The ADO.NET connection string to write through.</param>
@@ -297,7 +303,8 @@ public class DatabaseGateway : IDatabaseGateway
/// <param name="parameters">Materialised CLR parameter values (may be empty).</param>
/// <param name="cancellationToken">Cancellation token for the write.</param>
/// <returns>A task that completes when the write succeeds.</returns>
/// <exception cref="TransientDatabaseException">Thrown for a transient SQL error number.</exception>
/// <exception cref="OperationCanceledException">Rethrown unchanged when the caller's <paramref name="cancellationToken"/> requested cancellation.</exception>
/// <exception cref="TransientDatabaseException">Thrown for a transient SQL error number or a non-Sql transport/connection outage.</exception>
/// <exception cref="PermanentDatabaseException">Thrown for a permanent (or unknown) SQL error number.</exception>
internal virtual async Task ExecuteWriteAsync(
string connectionName,
@@ -306,20 +313,28 @@ public class DatabaseGateway : IDatabaseGateway
IReadOnlyDictionary<string, object?> parameters,
CancellationToken cancellationToken)
{
// M2.3 (#7) code-review fix: the catch ordering MIRRORS
// ExternalSystemClient.InvokeHttpAsync exactly so the SQL path classifies
// a live outage the same way the HTTP path does:
// 1. caller-requested cancellation propagates UNCHANGED (never a "DB error");
// 2. a SqlException is classified by error number (transient/permanent);
// 3. a NON-SqlException transport/connection failure (InvalidOperationException
// "connection not open", IOException, SocketException, TimeoutException,
// a non-Sql DbException, …) is TRANSIENT — buffered + retried, because a
// retry can succeed once the server is reachable. The pre-fix code only
// caught SqlException, so these escaped unclassified and crashed the
// Script Execution Actor instead of buffering;
// 4. genuinely-unexpected exceptions (e.g. an authoring ArgumentException)
// propagate — same as the HTTP path lets unexpected exceptions escape.
try
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
using var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (key, value) in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = key.StartsWith('@') ? key : "@" + key;
parameter.Value = value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await RunSqlAsync(connectionString, sql, parameters, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// [2] The caller asked to abandon the work — propagate the cancellation
// unchanged; it must never be reclassified as a transient DB error.
throw;
}
catch (SqlException ex)
{
@@ -328,6 +343,50 @@ public class DatabaseGateway : IDatabaseGateway
// is the connection NAME, never the connection string.
throw SqlErrorClassifier.Throw(connectionName, ex);
}
catch (Exception ex) when (SqlErrorClassifier.IsTransient(ex))
{
// [1] A live outage that did not surface as a SqlException — treat as
// transient so the caller buffers + retries. The message uses the
// connection NAME, never the connection string (credential safety).
throw new TransientDatabaseException(
$"Transient database error on {connectionName}: {ex.Message}",
errorNumber: null,
ex);
}
}
/// <summary>
/// M2.3 (#7): the raw ADO.NET write — opens the connection, builds the
/// command, and executes it. Marked <c>internal virtual</c> so tests can throw
/// RAW outage-shaped exceptions (e.g. <see cref="InvalidOperationException"/>,
/// <see cref="System.Net.Sockets.SocketException"/>) through the PRODUCTION
/// classification in <see cref="ExecuteWriteAsync"/>. This is the SQL parallel
/// of <c>client.SendAsync</c> inside <see cref="ExternalSystemClient.InvokeHttpAsync"/>:
/// the actual I/O, wrapped by the ordered classification catches in the caller.
/// </summary>
/// <param name="connectionString">The ADO.NET connection string to write through.</param>
/// <param name="sql">The SQL statement to execute.</param>
/// <param name="parameters">Materialised CLR parameter values (may be empty).</param>
/// <param name="cancellationToken">Cancellation token for the write.</param>
/// <returns>A task that completes when the write succeeds.</returns>
internal virtual async Task RunSqlAsync(
string connectionString,
string sql,
IReadOnlyDictionary<string, object?> parameters,
CancellationToken cancellationToken)
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
using var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (key, value) in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = key.StartsWith('@') ? key : "@" + key;
parameter.Value = value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// ExternalSystemGateway-020: a JSON number that does not fit in Int64 must