9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
445 lines
22 KiB
C#
445 lines
22 KiB
C#
using System.Data.Common;
|
|
using System.Text.Json;
|
|
using Microsoft.Data.SqlClient;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
|
|
|
/// <summary>
|
|
/// Database access from scripts.
|
|
/// Database.Connection("name") — returns ADO.NET SqlConnection (connection pooling).
|
|
/// Database.CachedWrite("name", "sql", params) — submits to S&F engine.
|
|
/// </summary>
|
|
public class DatabaseGateway : IDatabaseGateway
|
|
{
|
|
private readonly IExternalSystemRepository _repository;
|
|
private readonly StoreAndForwardService? _storeAndForward;
|
|
private readonly ILogger<DatabaseGateway> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of <see cref="DatabaseGateway"/>.
|
|
/// </summary>
|
|
/// <param name="repository">Repository for resolving database connection definitions.</param>
|
|
/// <param name="logger">Logger for diagnostics.</param>
|
|
/// <param name="storeAndForward">Optional store-and-forward service for cached writes; null disables buffering.</param>
|
|
public DatabaseGateway(
|
|
IExternalSystemRepository repository,
|
|
ILogger<DatabaseGateway> logger,
|
|
StoreAndForwardService? storeAndForward = null)
|
|
{
|
|
_repository = repository;
|
|
_logger = logger;
|
|
_storeAndForward = storeAndForward;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DbConnection> GetConnectionAsync(
|
|
string connectionName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
|
if (definition == null)
|
|
{
|
|
throw new InvalidOperationException($"Database connection '{connectionName}' not found");
|
|
}
|
|
|
|
var connection = CreateConnection(definition.ConnectionString);
|
|
try
|
|
{
|
|
await connection.OpenAsync(cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
// OpenAsync failed (unreachable server, bad credentials, cancellation) —
|
|
// dispose the just-created connection before the exception propagates so
|
|
// it is not leaked.
|
|
await connection.DisposeAsync();
|
|
throw;
|
|
}
|
|
return connection;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the underlying ADO.NET connection for a connection string. Virtual so
|
|
/// tests can substitute a connection whose <c>OpenAsync</c> fails.
|
|
/// </summary>
|
|
/// <param name="connectionString">The ADO.NET connection string.</param>
|
|
/// <returns>A new <see cref="DbConnection"/> for the given connection string.</returns>
|
|
internal virtual DbConnection CreateConnection(string connectionString) =>
|
|
new SqlConnection(connectionString);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ExternalCallResult> CachedWriteAsync(
|
|
string connectionName,
|
|
string sql,
|
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
|
string? originInstanceName = null,
|
|
CancellationToken cancellationToken = default,
|
|
TrackedOperationId? trackedOperationId = null,
|
|
Guid? executionId = null,
|
|
string? sourceScript = null,
|
|
Guid? parentExecutionId = null)
|
|
{
|
|
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
|
if (definition == null)
|
|
{
|
|
throw new InvalidOperationException($"Database connection '{connectionName}' not found");
|
|
}
|
|
|
|
if (_storeAndForward == null)
|
|
{
|
|
throw new InvalidOperationException("Store-and-forward service not available for cached writes");
|
|
}
|
|
|
|
// Attempt the write IMMEDIATELY and classify the outcome, mirroring
|
|
// ExternalSystemClient.CachedCallAsync. The previous behaviour enqueued
|
|
// every write unconditionally and the S&F retry sweep then retried ALL
|
|
// failures forever — a permanent SQL error (constraint, syntax,
|
|
// permission) was never returned to the script and spun in the buffer
|
|
// indefinitely. Now:
|
|
// * success -> Delivered, NOT buffered;
|
|
// * PermanentDatabaseException -> Failed synchronously, NOT buffered;
|
|
// * TransientDatabaseException -> buffered to S&F for retry.
|
|
try
|
|
{
|
|
await ExecuteWriteAsync(
|
|
connectionName, definition.ConnectionString, sql, parameters ?? EmptyParameters, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// Immediate success — the write is done; do not buffer.
|
|
return new ExternalCallResult(Success: true, ResponseJson: null, ErrorMessage: null, WasBuffered: false);
|
|
}
|
|
catch (PermanentDatabaseException ex)
|
|
{
|
|
// Permanent failures are returned to the script and never buffered —
|
|
// mirrors the PermanentExternalSystemException branch on the API path.
|
|
_logger.LogWarning(
|
|
ex,
|
|
"CachedWrite to '{Connection}' failed permanently (SQL error {Number}); returning Failed without buffering.",
|
|
connectionName, ex.SqlErrorNumber);
|
|
return new ExternalCallResult(
|
|
Success: false, ResponseJson: null, ErrorMessage: $"Permanent database error: {ex.Message}", WasBuffered: false);
|
|
}
|
|
catch (TransientDatabaseException ex)
|
|
{
|
|
// Transient failure — hand to S&F so the retry sweep delivers it.
|
|
_logger.LogDebug(
|
|
ex,
|
|
"CachedWrite to '{Connection}' failed transiently (SQL error {Number}); buffering for retry.",
|
|
connectionName, ex.SqlErrorNumber);
|
|
}
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
ConnectionName = connectionName,
|
|
Sql = sql,
|
|
Parameters = parameters
|
|
});
|
|
|
|
// The entity's MaxRetries is a non-nullable int
|
|
// whose default is 0, and the Store-and-Forward engine interprets a stored
|
|
// MaxRetries of 0 as "no limit" (retry forever) — see
|
|
// StoreAndForwardMessage.MaxRetries ("0 = no limit") and the retry-sweep
|
|
// guard `MaxRetries > 0 && ...`. Passing 0 verbatim would turn every
|
|
// unconfigured cached write into an unbounded retry loop. A 0 is treated as
|
|
// "unset" and passed as null so the bounded S&F default applies; the
|
|
// RetryDelay default of TimeSpan.Zero is likewise unset.
|
|
await _storeAndForward.EnqueueAsync(
|
|
StoreAndForwardCategory.CachedDbWrite,
|
|
connectionName,
|
|
payload,
|
|
originInstanceName,
|
|
definition.MaxRetries > 0 ? definition.MaxRetries : null,
|
|
definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null,
|
|
// attemptImmediateDelivery: false — this method already
|
|
// made the write attempt above (the transient-classified failure is
|
|
// exactly why we are buffering). Letting EnqueueAsync re-invoke the
|
|
// delivery handler would execute the same write a second time —
|
|
// mirrors ExternalSystemClient.CachedCallAsync.
|
|
attemptImmediateDelivery: false,
|
|
// Pin the S&F message id to the
|
|
// TrackedOperationId so the retry loop can
|
|
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
|
|
// terminal cached-write telemetry. Null -> S&F mints its own GUID
|
|
// (legacy behaviour).
|
|
messageId: trackedOperationId?.ToString(),
|
|
// Thread the originating script execution's ExecutionId + SourceScript
|
|
// onto the buffered row so the retry-loop cached-write audit rows carry
|
|
// the same provenance the script-side cached rows do.
|
|
executionId: executionId,
|
|
sourceScript: sourceScript,
|
|
// Thread the spawning inbound-API request's ExecutionId onto the
|
|
// buffered row so the retry-loop cached-write audit rows correlate
|
|
// back to the cross-execution chain. Null for a non-routed run.
|
|
parentExecutionId: parentExecutionId);
|
|
|
|
// Buffered for retry — mirrors the API path's WasBuffered=true result.
|
|
return new ExternalCallResult(Success: true, ResponseJson: null, ErrorMessage: null, WasBuffered: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delivers a buffered CachedDbWrite during a store-and-forward retry
|
|
/// sweep — executes the SQL against the named connection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The outcome is classified, mirroring
|
|
/// <see cref="ExternalSystemClient.DeliverBufferedAsync"/>. Returns
|
|
/// <c>false</c> — so the S&F engine PARKS the message — when the
|
|
/// connection no longer exists, the payload is unreadable, or the SQL fails
|
|
/// with a PERMANENT error (constraint / syntax / permission). A TRANSIENT SQL
|
|
/// error (<see cref="TransientDatabaseException"/>) propagates so the engine
|
|
/// retries. The previous code rethrew on ANY SQL error, so a permanent
|
|
/// failure on the retry path looped forever.
|
|
/// </remarks>
|
|
/// <param name="message">The buffered store-and-forward message to deliver.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the delivery operation.</param>
|
|
/// <returns>A task that resolves to <c>true</c> on success, or <c>false</c> when the message must be parked.</returns>
|
|
/// <exception cref="TransientDatabaseException">Thrown on a transient SQL failure so the engine retries.</exception>
|
|
public async Task<bool> DeliverBufferedAsync(
|
|
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
|
|
{
|
|
// A malformed (not just empty/null-fielded)
|
|
// PayloadJson would otherwise throw `JsonException` here, which the S&F
|
|
// engine treats as a transient failure and retries forever (poison
|
|
// message). Re-running the same deserialization against the same payload
|
|
// will throw deterministically, so JsonException is permanent — log,
|
|
// and return false so the S&F engine parks the message instead.
|
|
CachedWritePayload? payload;
|
|
try
|
|
{
|
|
payload = JsonSerializer.Deserialize<CachedWritePayload>(message.PayloadJson);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(
|
|
ex,
|
|
"Buffered CachedDbWrite message {Id} has malformed JSON payload; parking.",
|
|
message.Id);
|
|
return false;
|
|
}
|
|
|
|
if (payload == null || string.IsNullOrEmpty(payload.ConnectionName) || string.IsNullOrEmpty(payload.Sql))
|
|
{
|
|
_logger.LogError("Buffered CachedDbWrite message {Id} has an unreadable payload; parking.", message.Id);
|
|
return false;
|
|
}
|
|
|
|
var definition = await ResolveConnectionAsync(payload.ConnectionName, cancellationToken);
|
|
if (definition == null)
|
|
{
|
|
_logger.LogError(
|
|
"Buffered DB write to '{Connection}' cannot be delivered — the connection no longer exists; parking.",
|
|
payload.ConnectionName);
|
|
return false;
|
|
}
|
|
|
|
// Materialise the buffered JsonElement parameters into CLR values once,
|
|
// then run through the shared ExecuteWriteAsync seam so both the
|
|
// immediate-attempt path and this retry path classify SqlException the
|
|
// same way.
|
|
IReadOnlyDictionary<string, object?> materialisedParameters =
|
|
payload.Parameters == null
|
|
? EmptyParameters
|
|
: payload.Parameters.ToDictionary(
|
|
kv => kv.Key, kv => (object?)JsonElementToParameterValue(kv.Value));
|
|
|
|
try
|
|
{
|
|
await ExecuteWriteAsync(
|
|
payload.ConnectionName, definition.ConnectionString, payload.Sql, materialisedParameters, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
catch (PermanentDatabaseException ex)
|
|
{
|
|
// Permanent — parking is correct; retrying the identical statement
|
|
// cannot succeed. Mirrors ExternalSystemClient.DeliverBufferedAsync
|
|
// returning false on PermanentExternalSystemException.
|
|
_logger.LogError(
|
|
ex,
|
|
"Buffered DB write to '{Connection}' failed permanently (SQL error {Number}); parking.",
|
|
payload.ConnectionName, ex.SqlErrorNumber);
|
|
return false;
|
|
}
|
|
// TransientDatabaseException propagates — the S&F engine retries.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reusable empty parameter map so the no-parameter paths do not allocate a
|
|
/// fresh dictionary each call.
|
|
/// </summary>
|
|
private static readonly IReadOnlyDictionary<string, object?> EmptyParameters =
|
|
new Dictionary<string, object?>();
|
|
|
|
/// <summary>
|
|
/// Executes a parameterised SQL write against the given connection
|
|
/// 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 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>
|
|
/// <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>
|
|
/// <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,
|
|
string connectionString,
|
|
string sql,
|
|
IReadOnlyDictionary<string, object?> parameters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 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 RunSqlAsync(connectionString, sql, parameters, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// [1] 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)
|
|
{
|
|
// [2] A caller-token cancellation can surface
|
|
// from the SQL driver as a SqlException (a mid-flight cancel), not an
|
|
// OperationCanceledException, so the [1] filter above never sees it.
|
|
// Re-check the caller's token at the TOP of this block so such a cancel
|
|
// propagates as OperationCanceledException regardless of the driver's
|
|
// exception shape — never reclassified as a permanent DB error (the
|
|
// "-008 cancel-not-reclassified" contract). Version-independent: no need
|
|
// to match a specific SqlException number.
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Otherwise classify by SqlException.Number and rethrow as the
|
|
// strongly-typed transient / permanent failure the callers branch on.
|
|
// The context is the connection NAME, never the connection string.
|
|
throw SqlErrorClassifier.Throw(connectionName, ex);
|
|
}
|
|
catch (Exception ex) when (SqlErrorClassifier.IsTransient(ex))
|
|
{
|
|
// [3] 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>
|
|
/// 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);
|
|
}
|
|
|
|
// A JSON number that does not fit in Int64 must
|
|
// prefer decimal over double — a script's decimal SQL parameter is
|
|
// serialised as JSON without a type tag, and downcasting it to double on
|
|
// the cached-write retry path silently loses precision (e.g.
|
|
// 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
|
|
// long first (whole-number fast path), then decimal (preserves authored
|
|
// precision for typical money/measurement values), and only fall through
|
|
// to double for genuinely out-of-decimal-range values (very large
|
|
// scientific-notation floats).
|
|
/// <summary>
|
|
/// Converts a <see cref="JsonElement"/> to the most appropriate SQL parameter value,
|
|
/// preferring <c>long</c> → <c>decimal</c> → <c>double</c> for numeric kinds to preserve precision.
|
|
/// </summary>
|
|
/// <param name="element">The JSON element to convert.</param>
|
|
/// <returns>A boxed CLR value suitable for use as an ADO.NET parameter, or <see cref="DBNull.Value"/> for null/undefined.</returns>
|
|
internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
|
|
{
|
|
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
|
|
JsonValueKind.Number => element.TryGetInt64(out var l)
|
|
? l
|
|
: element.TryGetDecimal(out var dec)
|
|
? dec
|
|
: element.GetDouble(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
|
|
_ => element.GetRawText()
|
|
};
|
|
|
|
private sealed record CachedWritePayload(
|
|
string ConnectionName,
|
|
string Sql,
|
|
Dictionary<string, JsonElement>? Parameters);
|
|
|
|
private async Task<DatabaseConnectionDefinition?> ResolveConnectionAsync(
|
|
string connectionName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Name-keyed repository lookup instead of
|
|
// fetch-all-then-filter — connection definitions are resolved on every
|
|
// cached write / connection request, so the repository performs an indexed
|
|
// query rather than loading every connection into memory.
|
|
return await _repository.GetDatabaseConnectionByNameAsync(connectionName, cancellationToken);
|
|
}
|
|
}
|