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; /// /// Database access from scripts. /// Database.Connection("name") — returns ADO.NET SqlConnection (connection pooling). /// Database.CachedWrite("name", "sql", params) — submits to S&F engine. /// public class DatabaseGateway : IDatabaseGateway { private readonly IExternalSystemRepository _repository; private readonly StoreAndForwardService? _storeAndForward; private readonly ILogger _logger; /// /// Initializes a new instance of . /// /// Repository for resolving database connection definitions. /// Logger for diagnostics. /// Optional store-and-forward service for cached writes; null disables buffering. public DatabaseGateway( IExternalSystemRepository repository, ILogger logger, StoreAndForwardService? storeAndForward = null) { _repository = repository; _logger = logger; _storeAndForward = storeAndForward; } /// public async Task 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; } /// /// Creates the underlying ADO.NET connection for a connection string. Virtual so /// tests can substitute a connection whose OpenAsync fails. /// /// The ADO.NET connection string. /// A new for the given connection string. internal virtual DbConnection CreateConnection(string connectionString) => new SqlConnection(connectionString); /// public async Task CachedWriteAsync( string connectionName, string sql, IReadOnlyDictionary? 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); } /// /// Delivers a buffered CachedDbWrite during a store-and-forward retry /// sweep — executes the SQL against the named connection. /// /// /// The outcome is classified, mirroring /// . Returns /// false — 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 () propagates so the engine /// retries. The previous code rethrew on ANY SQL error, so a permanent /// failure on the retry path looped forever. /// /// The buffered store-and-forward message to deliver. /// Cancellation token for the delivery operation. /// A task that resolves to true on success, or false when the message must be parked. /// Thrown on a transient SQL failure so the engine retries. public async Task 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(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 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. } /// /// Reusable empty parameter map so the no-parameter paths do not allocate a /// fresh dictionary each call. /// private static readonly IReadOnlyDictionary EmptyParameters = new Dictionary(); /// /// Executes a parameterised SQL write against the given connection /// string and classifies the outcome into /// / , /// mirroring the ordered catches of /// on the API path: /// caller-requested cancellation propagates unchanged; a /// is classified by error number via ; a /// non- transport/connection outage is classified /// transient via ; /// genuinely-unexpected exceptions propagate. This is the single classification /// seam shared by the immediate attempt and the /// retry path. Marked internal virtual /// so tests can substitute already-classified outcomes; the raw I/O lives in /// the inner seam so tests can also drive raw outage /// exceptions through this classification (without fabricating a /// , which has no public constructor). /// /// 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). /// The ADO.NET connection string to write through. /// The SQL statement to execute. /// Materialised CLR parameter values (may be empty). /// Cancellation token for the write. /// A task that completes when the write succeeds. /// Rethrown unchanged when the caller's requested cancellation. /// Thrown for a transient SQL error number or a non-Sql transport/connection outage. /// Thrown for a permanent (or unknown) SQL error number. internal virtual async Task ExecuteWriteAsync( string connectionName, string connectionString, string sql, IReadOnlyDictionary 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); } } /// /// The raw ADO.NET write — opens the connection, builds the /// command, and executes it. Marked internal virtual so tests can throw /// RAW outage-shaped exceptions (e.g. , /// ) through the PRODUCTION /// classification in . This is the SQL parallel /// of client.SendAsync inside : /// the actual I/O, wrapped by the ordered classification catches in the caller. /// /// The ADO.NET connection string to write through. /// The SQL statement to execute. /// Materialised CLR parameter values (may be empty). /// Cancellation token for the write. /// A task that completes when the write succeeds. internal virtual async Task RunSqlAsync( string connectionString, string sql, IReadOnlyDictionary 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). /// /// Converts a to the most appropriate SQL parameter value, /// preferring longdecimaldouble for numeric kinds to preserve precision. /// /// The JSON element to convert. /// A boxed CLR value suitable for use as an ADO.NET parameter, or for null/undefined. 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? Parameters); private async Task 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); } }