using System.Data.Common;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
/// Executes one poll pass: resolves each requested reference to a , folds
/// the definitions into the minimum number of queries via , runs one
/// command per group under a bounded deadline, and slices each result set back to per-tag
/// s — positionally aligned with the caller's reference list
/// (design §3.2). Structurally the SQL analogue of ModbusDriver.ReadCoalescedAsync: group → one
/// round-trip → slice back.
/// N in, N out, in order. The returned list always has exactly one entry per requested
/// reference, at the same index, whatever happened. Per-tag failures are Bad-coded snapshots, never
/// exceptions (the contract) — see for which code
/// means what. This matters more here than in a point-to-point driver: one result set feeds many tags,
/// so a slicing defect does not fail loudly — it publishes one tag's value onto another tag's
/// node.
/// The whole call throws only when the database is unreachable — that is, when opening a
/// connection fails. That surfaces to as a poll failure and earns the
/// capped-exponential backoff; the driver's IHostConnectivityProbe is what scopes Bad quality
/// across the subtree during an outage, so nothing is lost by not manufacturing per-tag snapshots for
/// an outage. A query that fails after the connection opened is the group's problem, not the
/// server's, and Bad-codes that group only.
/// Deadlines (design §8.3, the R2-01 frozen-peer lesson). Two independent mechanisms, both
/// required. CommandTimeout is the server-side backstop and bounds nothing on a wedged
/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see
/// . A frozen database yields
/// snapshots — it must never wedge the poll thread.
/// Not a driver. This type owns the read path only; it deliberately holds no health state,
/// no subscription state and no connection between polls, so the driver shell can wrap it in
/// and own those concerns.
///
public sealed class SqlPollReader
{
/// Minimum spacing between "your source violates the query contract" warnings, per reader.
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
private readonly DbProviderFactory _factory;
private readonly string _connectionString;
private readonly ISqlDialect _dialect;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly bool _nullIsBad;
private readonly Func _resolve;
private readonly ILogger _logger;
///
/// Caps concurrently executing group queries — and therefore concurrently held connections
/// (design §8.4). Never disposed: a only needs disposal once its
/// AvailableWaitHandle has been touched, and not disposing it also removes the hazard of a
/// timed-out-but-still-running group releasing into a disposed semaphore.
///
private readonly SemaphoreSlim _gate;
private long _lastContractWarningTicks;
/// Initializes a new instance of the class.
///
/// Creates the provider's connections. Passed explicitly rather than taken from
/// so the driver can hand in an instrumented or pre-configured factory.
///
///
/// The resolved connection string (never the authored connectionStringRef — secrets are
/// resolved by the driver at Initialize, design §8.2).
///
/// Supplies identifier quoting, row-limit syntax, and column-type mapping.
///
/// The ADO.NET CommandTimeout backstop. Rounded up to whole seconds and floored at 1,
/// because ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value this option
/// must never silently become.
///
///
/// The wall-clock ceiling on one group's whole operation — waiting for a concurrency slot, opening
/// the connection, and running the query. A breach Bad-codes that group with
/// .
/// Authoring should keep this strictly greater than
/// (design §8.3): inverted, the client-side abort always fires first and masks the server-side
/// backstop. That rule is not enforced here — it belongs to config validation, and this type
/// stays usable for the tests that must deliberately invert it.
///
///
/// Maximum group queries — and connections — in flight at once. A timed-out group keeps its slot
/// until it truly finishes, so this is a hard ceiling on connections even against a frozen database.
/// See for why a high value is safe under
/// Microsoft.Data.SqlClient but wants sizing against thread-pool headroom under a provider
/// that blocks synchronously.
///
///
/// publishes for a NULL value cell instead
/// of the default . It governs a present row with a
/// NULL cell only — an absent row is always .
///
///
/// RawPath → tag definition; on a miss. Shaped to match
/// 's lookup so the driver can pass its authored table
/// straight through.
///
/// Optional; defaults to a no-op logger.
/// A required reference argument is null.
/// is blank.
/// A timeout is non-positive, or the cap is below 1.
public SqlPollReader(
DbProviderFactory factory,
string connectionString,
ISqlDialect dialect,
TimeSpan commandTimeout,
TimeSpan operationTimeout,
int maxConcurrentGroups,
bool nullIsBad,
Func resolve,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
ArgumentNullException.ThrowIfNull(resolve);
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql poll reader needs a connection string.", nameof(connectionString));
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(commandTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operationTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrentGroups, 1);
_factory = factory;
_connectionString = connectionString;
_dialect = dialect;
_operationTimeout = operationTimeout;
_commandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
_nullIsBad = nullIsBad;
_resolve = resolve;
_logger = logger ?? NullLogger.Instance;
_gate = new SemaphoreSlim(maxConcurrentGroups, maxConcurrentGroups);
}
///
/// Reads every reference in one poll pass. Matches 's shape so the
/// driver shell can delegate to it directly.
///
/// The RawPaths to read. Empty yields an empty result and touches no connection.
///
/// The caller's token. Its cancellation propagates as an
/// — the engine asked the poll to stop, and nobody is
/// waiting for the snapshots. This is deliberately asymmetric with an operationTimeout
/// breach, which is a data-quality fact clients must see and so becomes
/// snapshots.
///
/// One snapshot per reference, at the same index.
/// is null.
/// was cancelled.
/// The database could not be reached (opening a connection failed).
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
if (results.Length == 0) return results;
var readAt = DateTime.UtcNow;
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
// whose members are the sole thing the slice-back walks.
var definitions = new List(fullReferences.Count);
var slots = new Dictionary>();
for (var i = 0; i < fullReferences.Count; i++)
{
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
if (definition is null)
{
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
continue;
}
definitions.Add(definition);
if (!slots.TryGetValue(definition, out var positions))
slots[definition] = positions = [];
positions.Add(i);
}
if (definitions.Count > 0)
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
for (var i = 0; i < results.Length; i++)
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
return results;
}
/// Plans the resolved definitions, runs every group concurrently, and slices the results back.
private async Task ReadGroupsAsync(
List definitions,
Dictionary> slots,
DataValueSnapshot[] results,
DateTime readAt,
CancellationToken cancellationToken)
{
IReadOnlyList plans;
try
{
plans = SqlGroupPlanner.Plan(definitions, _dialect);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
// reaching here means a definition was built past the parser. It is an authoring fault, not a
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
definitions.Count);
foreach (var definition in definitions)
{
if (!slots.TryGetValue(definition, out var positions)) continue;
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
return;
}
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
// carries its own deadline, so the whole call is bounded by ~operationTimeout rather than by
// plans.Count × operationTimeout.
var groups = new Task[plans.Count];
for (var g = 0; g < plans.Count; g++)
groups[g] = RunGroupAsync(plans[g], cancellationToken);
// WhenAll waits for every group before surfacing a fault, so a thrown "database unreachable" never
// leaves a sibling group running against a connection nobody is watching.
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
for (var g = 0; g < plans.Count; g++)
Slice(plans[g], outcomes[g], slots, results, readAt);
}
///
/// Runs one group's query under a hard wall-clock ceiling.
/// Why the ceiling is Task.WaitAsync and not just a linked
/// CancelAfter. A linked token only bounds a
/// provider that honours it. The S7 R2-01 finding was exactly a provider whose async path
/// ignored its deadline, and ADO.NET has the same shape: some providers implement
/// ExecuteReaderAsync synchronously, and even a genuinely async one can hang inside its own
/// cancellation handshake against a frozen socket. Both are used here: the linked token so a
/// well-behaved provider unwinds cleanly and releases its connection, and WaitAsync so
/// control returns at the deadline regardless.
/// The work is started on the thread pool so a provider that blocks synchronously blocks a
/// pool thread rather than the caller — without that, a synchronous provider never yields the task
/// there would be to bound.
/// Thread-pool scheduling and what a BadTimeout therefore means. The group's clock
/// starts before Task.Run, so in principle a group could burn budget queueing for a pool
/// thread rather than waiting on the database. TaskCreationOptions.LongRunning (a dedicated
/// OS thread) was considered and rejected: it would create and tear down one thread per group
/// per poll, forever, on a driver whose whole job is to poll on a short cadence — a permanent cost
/// paid against a hazard that cannot arise with the provider this driver actually ships.
/// Microsoft.Data.SqlClient's async path is genuinely asynchronous: the delegate reaches its
/// first real await within microseconds and returns the pool thread, so a frozen SQL Server parks
/// zero pool threads however long it stays frozen, and the driver cannot starve itself no
/// matter how high maxConcurrentGroups goes. The queueing scenario needs a provider that
/// degrades to synchronous blocking — which is exactly what the SQLite test rig exploits on purpose,
/// and is not a shipped configuration. Consequence for outage diagnosis (e.g. blackhole-testing a
/// paused SQL Server): under Microsoft.Data.SqlClient a
/// means the database did not answer inside
/// operationTimeout — it cannot be an artefact of this driver's own pool pressure. Under a
/// synchronously-blocking provider it may also include queueing delay, so size
/// maxConcurrentGroups below the process's readily-available worker count there.
/// The concurrency slot is released by the work, not by the waiter. A timed-out group
/// keeps its slot until it truly finishes, so a frozen database can never accumulate more than
/// maxConcurrentGroups connections however long it stays frozen. The slot wait is itself
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.
///
private async Task RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var clock = Stopwatch.StartNew();
if (!await _gate.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false))
return GroupResult.TimedOut;
var handedOff = false;
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task work;
try
{
var budget = Remaining(clock);
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
deadline.CancelAfter(budget);
work = Task.Run(
async () =>
{
try
{
return await QueryAsync(plan, deadline.Token).ConfigureAwait(false);
}
finally
{
_gate.Release();
deadline.Dispose();
}
},
CancellationToken.None);
handedOff = true;
}
finally
{
// Ownership of both the slot and the CTS transfers to the work task; release them here only on
// the paths where that task was never created.
if (!handedOff)
{
_gate.Release();
deadline.Dispose();
}
}
try
{
return await work.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
// Our wall-clock bound fired while the provider was still inside the call.
return TimedOut(plan, work);
}
catch (OperationCanceledException)
{
// Either the caller pulled the plug, or our deadline fired and the provider DID honour the
// linked token. Both leave the work running with nobody to observe its fault.
if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work);
Detach(work);
throw;
}
}
/// How much of this group's operationTimeout budget is left; never negative.
private TimeSpan Remaining(Stopwatch clock)
{
var remaining = _operationTimeout - clock.Elapsed;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// Records the deadline breach and detaches the abandoned work.
private GroupResult TimedOut(SqlQueryPlan plan, Task work)
{
// The table is named for the same reason the contract warnings name it: under a partial outage this
// line repeats every poll, and "which source is frozen" is the only question the operator has.
_logger.LogWarning(
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
"timeout; publishing BadTimeout.",
plan.Model, plan.Members[0].Table, plan.Members.Count,
(int)_operationTimeout.TotalMilliseconds);
Detach(work);
return GroupResult.TimedOut;
}
///
/// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task
/// exception. The task still owns its connection and still releases the concurrency slot when it
/// finally completes — that is what keeps a frozen database from accumulating connections.
///
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
///
/// Opens a connection, executes the plan's single command, and materialises the result set.
/// The rows are read into memory before the reader is disposed — the slice-back needs
/// random access across members, and holding a reader (and therefore a connection) open across that
/// work is exactly how a poll loop exhausts a pool.
///
private async Task QueryAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"The {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
// Deliberately OUTSIDE the catch below: a failed open means the database is unreachable, which
// is the one condition IReadable says the whole call may throw on (→ PollGroupEngine backoff).
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
try
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = plan.SqlText;
command.CommandTimeout = _commandTimeoutSeconds;
for (var i = 0; i < plan.ParameterNames.Count; i++)
{
var parameter = command.CreateParameter();
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
parameter.ParameterName = plan.ParameterNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
command.Parameters.Add(parameter);
}
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
return await MaterialiseAsync(plan, reader, cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// The connection opened, so the server is up — this query is what failed (an identifier that
// is not in the catalog, a lock, a permission). That is this group's problem alone.
_logger.LogWarning(
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
plan.Model, plan.Members.Count, plan.SqlText);
return GroupResult.Failed;
}
}
}
///
/// Reads every row into memory, projected onto . Row
/// fetching stays on the async path so a result set that streams slowly still honours the deadline
/// token mid-read, rather than only at the ExecuteReader boundary.
///
private async Task MaterialiseAsync(
SqlQueryPlan plan, DbDataReader reader, CancellationToken cancellationToken)
{
var selected = plan.SelectedColumns;
var ordinals = new int[selected.Count];
var typeNames = new string[selected.Count];
var columnIndex = new Dictionary(selected.Count, StringComparer.Ordinal);
for (var c = 0; c < selected.Count; c++)
{
// GetOrdinal rather than the position in SelectedColumns: the list is documented to be in
// ordinal order, but resolving it against the live result set is what makes a wrong slice
// impossible rather than merely unlikely.
ordinals[c] = reader.GetOrdinal(selected[c]);
typeNames[c] = SafeTypeName(reader, ordinals[c]);
columnIndex[selected[c]] = c;
}
var rows = new List