using System.Data.Common;
using System.Text.Json;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.StoreAndForward;
namespace ScadaLink.ExternalSystemGateway;
///
/// WP-9: 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;
public DatabaseGateway(
IExternalSystemRepository repository,
ILogger logger,
StoreAndForwardService? storeAndForward = null)
{
_repository = repository;
_logger = logger;
_storeAndForward = storeAndForward;
}
///
/// Returns an open SqlConnection from the named database connection definition.
/// Connection pooling is managed by the underlying ADO.NET provider.
///
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 (ExternalSystemGateway-010).
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.
///
internal virtual DbConnection CreateConnection(string connectionString) =>
new SqlConnection(connectionString);
///
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
///
///
/// Audit Log #23 (M3): used as the S&F message id so the retry loop can
/// recover it via StoreAndForwardMessage.Id and emit per-attempt /
/// terminal cached-write telemetry (Tasks E4/E5). Null preserves the
/// pre-M3 behaviour (S&F mints a random GUID).
///
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)
{
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");
}
var payload = JsonSerializer.Serialize(new
{
ConnectionName = connectionName,
Sql = sql,
Parameters = parameters
});
// ExternalSystemGateway-015: 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,
// Audit Log #23 (M3): pin the S&F message id to the
// TrackedOperationId so the retry loop (Bundle E Tasks E4/E5) can
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
// terminal cached-write telemetry. Null -> S&F mints its own GUID
// (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): 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);
}
///
/// WP-9/10: Delivers a buffered CachedDbWrite during a store-and-forward retry
/// sweep — executes the SQL against the named connection. Returns true on
/// success, false if the connection no longer exists (the message is parked);
/// throws on any execution error so the engine retries.
///
public async Task DeliverBufferedAsync(
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
{
var payload = JsonSerializer.Deserialize(message.PayloadJson);
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;
}
await using var connection = new SqlConnection(definition.ConnectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = payload.Sql;
if (payload.Parameters != null)
{
foreach (var (key, value) in payload.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = key.StartsWith('@') ? key : "@" + key;
parameter.Value = JsonElementToParameterValue(value);
command.Parameters.Add(parameter);
}
}
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
private static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : 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)
{
// ExternalSystemGateway-011: 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);
}
}