de375ff7ea
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.
106 lines
4.9 KiB
C#
106 lines
4.9 KiB
C#
using System.Data.Common;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
|
|
|
|
/// <summary>
|
|
/// M2.3 (#7): unit tests for the transient-vs-permanent SQL error-number
|
|
/// classifier that <c>DatabaseGateway</c> uses to decide whether a failed
|
|
/// cached write should be buffered (transient) or returned to the script
|
|
/// synchronously / parked (permanent).
|
|
/// </summary>
|
|
public class SqlErrorClassifierTests
|
|
{
|
|
// The full transient set documented on SqlErrorClassifier — connection,
|
|
// timeout, deadlock, and Azure throttle error numbers. A retry can plausibly
|
|
// succeed for any of these, so they are buffered to store-and-forward.
|
|
[Theory]
|
|
[InlineData(-2)] // timeout expired
|
|
[InlineData(-1)] // connection error
|
|
[InlineData(2)] // network / instance not found
|
|
[InlineData(53)] // network path not found
|
|
[InlineData(64)] // connection terminated mid-session
|
|
[InlineData(233)] // no process on the other end of the pipe
|
|
[InlineData(1205)] // deadlock victim
|
|
[InlineData(10053)] // transport-level abort
|
|
[InlineData(10054)] // connection reset by peer
|
|
[InlineData(10060)] // connection timed out
|
|
[InlineData(40197)] // Azure SQL service error, retry
|
|
[InlineData(40501)] // Azure SQL service busy
|
|
[InlineData(40613)] // Azure SQL database unavailable
|
|
[InlineData(49918)] // Azure SQL cannot process request (throttle)
|
|
[InlineData(49919)] // Azure SQL too many create/update operations
|
|
[InlineData(49920)] // Azure SQL too many operations (throttle)
|
|
public void IsTransient_KnownTransientNumber_ReturnsTrue(int errorNumber)
|
|
{
|
|
Assert.True(SqlErrorClassifier.IsTransient(errorNumber));
|
|
}
|
|
|
|
// Constraint, syntax, and permission errors are permanent — retrying the
|
|
// identical statement cannot succeed and may cause duplicate side effects.
|
|
[Theory]
|
|
[InlineData(547)] // constraint violation (FK/CHECK)
|
|
[InlineData(2627)] // primary-key / unique constraint violation
|
|
[InlineData(2601)] // duplicate key in a unique index
|
|
[InlineData(102)] // incorrect syntax
|
|
[InlineData(156)] // incorrect syntax near a keyword
|
|
[InlineData(207)] // invalid column name
|
|
[InlineData(208)] // invalid object name
|
|
[InlineData(229)] // permission denied on object
|
|
[InlineData(230)] // permission denied on column
|
|
[InlineData(262)] // permission denied (CREATE etc.)
|
|
public void IsTransient_KnownPermanentNumber_ReturnsFalse(int errorNumber)
|
|
{
|
|
Assert.False(SqlErrorClassifier.IsTransient(errorNumber));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)] // no error number captured
|
|
[InlineData(99999)] // unknown / undocumented number
|
|
[InlineData(12345)]
|
|
[InlineData(int.MaxValue)]
|
|
public void IsTransient_UnknownNumber_DefaultsToPermanent(int errorNumber)
|
|
{
|
|
// Fail-fast is the safer default: an unrecognised error number must NOT
|
|
// be silently retried forever. Unknown => permanent => false.
|
|
Assert.False(SqlErrorClassifier.IsTransient(errorNumber));
|
|
}
|
|
|
|
// ── M2.3 (#7) code-review fix: IsTransient(Exception) — a live DB outage does
|
|
// not always surface as a SqlException. Transport/connection/timeout/driver
|
|
// exception types are transient (buffer+retry), mirroring the HTTP path's
|
|
// ErrorClassifier.IsTransient(Exception). ──
|
|
|
|
public static IEnumerable<object[]> TransientExceptionTypes()
|
|
{
|
|
yield return new object[] { new InvalidOperationException("connection not open") };
|
|
yield return new object[] { new System.IO.IOException("transport reset") };
|
|
yield return new object[] { new System.Net.Sockets.SocketException(10060) };
|
|
yield return new object[] { new TimeoutException("timed out") };
|
|
yield return new object[] { new TaskCanceledException("driver-level cancellation") };
|
|
// Any DbException that is NOT a SqlException is a driver/transport error.
|
|
yield return new object[] { new NonSqlDbException("provider transport error") };
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(TransientExceptionTypes))]
|
|
public void IsTransient_Exception_TrueForTransportTypes(Exception ex)
|
|
{
|
|
Assert.True(SqlErrorClassifier.IsTransient(ex));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsTransient_Exception_FalseForUnexpectedType()
|
|
{
|
|
// Authoring bugs are NOT a DB outage — they must propagate, exactly as the
|
|
// HTTP path lets genuinely-unexpected exceptions escape its IsTransient filter.
|
|
Assert.False(SqlErrorClassifier.IsTransient(new ArgumentException("authoring bug")));
|
|
Assert.False(SqlErrorClassifier.IsTransient(new NullReferenceException()));
|
|
}
|
|
|
|
/// <summary>A concrete <see cref="DbException"/> that is not a SqlException, for the classifier unit test.</summary>
|
|
private sealed class NonSqlDbException : DbException
|
|
{
|
|
public NonSqlDbException(string message) : base(message) { }
|
|
}
|
|
}
|