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 long → decimal → double 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);
}
}