967e5140f1
Executes a poll pass: resolve refs -> SqlGroupPlanner.Plan -> one command per group -> slice the result set back to per-tag DataValueSnapshots, positionally aligned with the caller's reference list (design §3.2). The frozen-peer contract (design §8.3, the R2-01 S7 lesson) is the core of it. CommandTimeout is only the server-side backstop; the client-side bound is a linked CancelAfter PLUS Task.WaitAsync, because a linked token bounds only a provider that honours it and ADO.NET providers vary. The whole per-group operation — waiting for a concurrency slot, OpenAsync, and the query — runs off one operationTimeout budget, so ReadAsync returns on time regardless of what the database is doing. A breach Bad-codes that group (BadTimeout); caller cancellation propagates instead, since nobody consumes a torn-down poll. Also: absent row (BadNoData) stays distinct from a NULL cell (Uncertain, or Bad under nullIsBad); duplicate plan members are all fed (two tags on one keyValue bind one parameter but stay two members); the concurrency slot is released by the work, not the waiter, so a frozen database can never hold more than maxConcurrentGroups connections; the whole call throws only when the connection itself cannot open, which is what earns PollGroupEngine's backoff. SqlStatusCodes is driver-local, matching every sibling driver's own table — there is no shared helper in Core.Abstractions and drivers do not reference the OPC UA SDK. Values were read off Opc.Ua.StatusCodes rather than copied from a sibling, because two of those tables carry transcription errors. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
771 lines
39 KiB
C#
771 lines
39 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Executes one poll pass: resolves each requested reference to a <see cref="SqlTagDefinition"/>, folds
|
||
/// the definitions into the minimum number of queries via <see cref="SqlGroupPlanner"/>, runs one
|
||
/// command per group under a bounded deadline, and slices each result set back to per-tag
|
||
/// <see cref="DataValueSnapshot"/>s — <b>positionally aligned with the caller's reference list</b>
|
||
/// (design §3.2). Structurally the SQL analogue of <c>ModbusDriver.ReadCoalescedAsync</c>: group → one
|
||
/// round-trip → slice back.
|
||
/// <para><b>N in, N out, in order.</b> 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 <see cref="IReadable"/> contract) — see <see cref="SqlStatusCodes"/> 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 <em>one tag's value onto another tag's
|
||
/// node</em>.</para>
|
||
/// <para><b>The whole call throws only when the database is unreachable</b> — that is, when opening a
|
||
/// connection fails. That surfaces to <see cref="PollGroupEngine"/> as a poll failure and earns the
|
||
/// capped-exponential backoff; the driver's <c>IHostConnectivityProbe</c> 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 <em>after</em> the connection opened is the group's problem, not the
|
||
/// server's, and Bad-codes that group only.</para>
|
||
/// <para><b>Deadlines (design §8.3, the R2-01 frozen-peer lesson).</b> Two independent mechanisms, both
|
||
/// required. <c>CommandTimeout</c> is the <em>server-side</em> backstop and bounds nothing on a wedged
|
||
/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see
|
||
/// <see cref="RunGroupAsync"/>. A frozen database yields <see cref="SqlStatusCodes.BadTimeout"/>
|
||
/// snapshots — it must never wedge the poll thread.</para>
|
||
/// <para><b>Not a driver.</b> 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
|
||
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
|
||
/// </summary>
|
||
public sealed class SqlPollReader
|
||
{
|
||
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
|
||
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<string, SqlTagDefinition?> _resolve;
|
||
private readonly ILogger _logger;
|
||
|
||
/// <summary>
|
||
/// Caps concurrently executing group queries — and therefore concurrently held connections
|
||
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
|
||
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
|
||
/// timed-out-but-still-running group releasing into a disposed semaphore.
|
||
/// </summary>
|
||
private readonly SemaphoreSlim _gate;
|
||
|
||
private long _lastContractWarningTicks;
|
||
|
||
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
|
||
/// <param name="factory">
|
||
/// Creates the provider's connections. Passed explicitly rather than taken from
|
||
/// <paramref name="dialect"/> so the driver can hand in an instrumented or pre-configured factory.
|
||
/// </param>
|
||
/// <param name="connectionString">
|
||
/// The resolved connection string (never the authored <c>connectionStringRef</c> — secrets are
|
||
/// resolved by the driver at Initialize, design §8.2).
|
||
/// </param>
|
||
/// <param name="dialect">Supplies identifier quoting, row-limit syntax, and column-type mapping.</param>
|
||
/// <param name="commandTimeout">
|
||
/// The ADO.NET <c>CommandTimeout</c> backstop. Rounded <b>up</b> to whole seconds and floored at 1,
|
||
/// because ADO.NET reads <c>CommandTimeout = 0</c> as "wait forever" — the one value this option
|
||
/// must never silently become.
|
||
/// </param>
|
||
/// <param name="operationTimeout">
|
||
/// 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
|
||
/// <see cref="SqlStatusCodes.BadTimeout"/>.
|
||
/// <para>Authoring should keep this strictly greater than <paramref name="commandTimeout"/>
|
||
/// (design §8.3): inverted, the client-side abort always fires first and masks the server-side
|
||
/// backstop. That rule is <b>not</b> enforced here — it belongs to config validation, and this type
|
||
/// stays usable for the tests that must deliberately invert it.</para>
|
||
/// </param>
|
||
/// <param name="maxConcurrentGroups">Maximum group queries — and connections — in flight at once.</param>
|
||
/// <param name="nullIsBad">
|
||
/// <see langword="true"/> publishes <see cref="SqlStatusCodes.Bad"/> for a NULL value cell instead
|
||
/// of the default <see cref="SqlStatusCodes.Uncertain"/>. It governs a <em>present</em> row with a
|
||
/// NULL cell only — an absent row is always <see cref="SqlStatusCodes.BadNoData"/>.
|
||
/// </param>
|
||
/// <param name="resolve">
|
||
/// RawPath → tag definition; <see langword="null"/> on a miss. Shaped to match
|
||
/// <see cref="EquipmentTagRefResolver{TDef}"/>'s lookup so the driver can pass its authored table
|
||
/// straight through.
|
||
/// </param>
|
||
/// <param name="logger">Optional; defaults to a no-op logger.</param>
|
||
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
|
||
/// <exception cref="ArgumentException"><paramref name="connectionString"/> is blank.</exception>
|
||
/// <exception cref="ArgumentOutOfRangeException">A timeout is non-positive, or the cap is below 1.</exception>
|
||
public SqlPollReader(
|
||
DbProviderFactory factory,
|
||
string connectionString,
|
||
ISqlDialect dialect,
|
||
TimeSpan commandTimeout,
|
||
TimeSpan operationTimeout,
|
||
int maxConcurrentGroups,
|
||
bool nullIsBad,
|
||
Func<string, SqlTagDefinition?> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads every reference in one poll pass. Matches <see cref="IReadable.ReadAsync"/>'s shape so the
|
||
/// driver shell can delegate to it directly.
|
||
/// </summary>
|
||
/// <param name="fullReferences">The RawPaths to read. Empty yields an empty result and touches no connection.</param>
|
||
/// <param name="cancellationToken">
|
||
/// The caller's token. Its cancellation <b>propagates</b> as an
|
||
/// <see cref="OperationCanceledException"/> — the engine asked the poll to stop, and nobody is
|
||
/// waiting for the snapshots. This is deliberately asymmetric with an <c>operationTimeout</c>
|
||
/// breach, which is a data-quality fact clients must see and so becomes
|
||
/// <see cref="SqlStatusCodes.BadTimeout"/> snapshots.
|
||
/// </param>
|
||
/// <returns>One snapshot per reference, at the same index.</returns>
|
||
/// <exception cref="ArgumentNullException"><paramref name="fullReferences"/> is null.</exception>
|
||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
|
||
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
|
||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||
IReadOnlyList<string> 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<SqlTagDefinition>(fullReferences.Count);
|
||
var slots = new Dictionary<SqlTagDefinition, List<int>>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
|
||
private async Task ReadGroupsAsync(
|
||
List<SqlTagDefinition> definitions,
|
||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||
DataValueSnapshot[] results,
|
||
DateTime readAt,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
IReadOnlyList<SqlQueryPlan> 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<GroupResult>[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs one group's query under a hard wall-clock ceiling.
|
||
/// <para><b>Why the ceiling is <c>Task.WaitAsync</c> and not just a linked
|
||
/// <c>CancelAfter</c>.</b> A linked token only bounds a
|
||
/// provider that <em>honours</em> 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
|
||
/// <c>ExecuteReaderAsync</c> 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 <c>WaitAsync</c> so
|
||
/// control returns at the deadline regardless.</para>
|
||
/// <para>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.</para>
|
||
/// <para><b>The concurrency slot is released by the work, not by the waiter.</b> A timed-out group
|
||
/// keeps its slot until it truly finishes, so a frozen database can never accumulate more than
|
||
/// <c>maxConcurrentGroups</c> 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.</para>
|
||
/// </summary>
|
||
private async Task<GroupResult> 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<GroupResult> 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;
|
||
}
|
||
}
|
||
|
||
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
|
||
private TimeSpan Remaining(Stopwatch clock)
|
||
{
|
||
var remaining = _operationTimeout - clock.Elapsed;
|
||
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
|
||
}
|
||
|
||
/// <summary>Records the deadline breach and detaches the abandoned work.</summary>
|
||
private GroupResult TimedOut(SqlQueryPlan plan, Task work)
|
||
{
|
||
_logger.LogWarning(
|
||
"Sql poll group ({Model}, {Members} tag(s)) exceeded the {Timeout} ms operation timeout; " +
|
||
"publishing BadTimeout.", plan.Model, plan.Members.Count, (int)_operationTimeout.TotalMilliseconds);
|
||
|
||
Detach(work);
|
||
return GroupResult.TimedOut;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static void Detach(Task work)
|
||
=> _ = work.ContinueWith(
|
||
static t => _ = t.Exception,
|
||
CancellationToken.None,
|
||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||
TaskScheduler.Default);
|
||
|
||
/// <summary>
|
||
/// Opens a connection, executes the plan's single command, and materialises the result set.
|
||
/// <para>The rows are read into memory <b>before</b> 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.</para>
|
||
/// </summary>
|
||
private async Task<GroupResult> 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads every row into memory, projected onto <see cref="SqlQueryPlan.SelectedColumns"/>. 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.
|
||
/// </summary>
|
||
private async Task<GroupResult> 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<string, int>(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<object?[]>();
|
||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||
{
|
||
var row = new object?[selected.Count];
|
||
for (var c = 0; c < selected.Count; c++)
|
||
row[c] = reader.IsDBNull(ordinals[c]) ? null : reader.GetValue(ordinals[c]);
|
||
rows.Add(row);
|
||
}
|
||
|
||
return GroupResult.Ok(rows, columnIndex, typeNames);
|
||
}
|
||
|
||
/// <summary>Reads a column's provider type name, falling back to the dialect's unknown-type default.</summary>
|
||
private static string SafeTypeName(DbDataReader reader, int ordinal)
|
||
{
|
||
try
|
||
{
|
||
return reader.GetDataTypeName(ordinal) ?? string.Empty;
|
||
}
|
||
catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException
|
||
or IndexOutOfRangeException)
|
||
{
|
||
// A provider that declines to report a type name must not fail the poll — the tag's declared
|
||
// type (or the dialect's String fallback) covers it.
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
|
||
private void Slice(
|
||
SqlQueryPlan plan,
|
||
GroupResult outcome,
|
||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||
DataValueSnapshot[] results,
|
||
DateTime readAt)
|
||
{
|
||
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
|
||
// where-column is not even in the result set (design §5.3).
|
||
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
|
||
? IndexByKey(plan, outcome)
|
||
: null;
|
||
|
||
foreach (var member in plan.Members)
|
||
{
|
||
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
|
||
if (!slots.TryGetValue(member, out var positions)) continue;
|
||
|
||
var snapshot = outcome.Succeeded
|
||
? MapMember(plan, member, outcome, byKey, readAt)
|
||
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
|
||
|
||
foreach (var position in positions) results[position] = snapshot;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
|
||
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
|
||
/// violation is reported through a rate-limited warning rather than silently.
|
||
/// </summary>
|
||
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
|
||
{
|
||
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
|
||
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
|
||
return index;
|
||
|
||
var duplicated = 0;
|
||
foreach (var row in outcome.Rows)
|
||
{
|
||
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
|
||
// matched by a tag whose keyValue is legitimately the empty string.
|
||
if (row[keyAt] is not { } keyCell) continue;
|
||
|
||
// Stringified because the bound keyValue is authored text while the column may be any type
|
||
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
|
||
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
|
||
if (key is null) continue;
|
||
if (!index.TryAdd(key, row))
|
||
{
|
||
index[key] = row;
|
||
duplicated++;
|
||
}
|
||
}
|
||
|
||
if (duplicated > 0 && ShouldWarnAboutContract())
|
||
{
|
||
_logger.LogWarning(
|
||
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
|
||
"last row wins. The key-value model requires one row per key — point the tag at a " +
|
||
"current-values table or an operator-authored latest-per-key view.",
|
||
plan.Members[0].Table, duplicated);
|
||
}
|
||
|
||
return index;
|
||
}
|
||
|
||
/// <summary>Maps one member's cell to a snapshot: value, quality, and source timestamp.</summary>
|
||
private DataValueSnapshot MapMember(
|
||
SqlQueryPlan plan,
|
||
SqlTagDefinition member,
|
||
GroupResult outcome,
|
||
Dictionary<string, object?[]>? byKey,
|
||
DateTime readAt)
|
||
{
|
||
object?[]? row;
|
||
string? valueColumn;
|
||
string? timestampColumn;
|
||
if (plan.Model == SqlTagModel.KeyValue)
|
||
{
|
||
valueColumn = plan.ValueColumn;
|
||
timestampColumn = plan.TimestampColumn;
|
||
row = member.KeyValue is { } key && byKey is not null ? byKey.GetValueOrDefault(key) : null;
|
||
}
|
||
else
|
||
{
|
||
valueColumn = member.ColumnName;
|
||
// A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are
|
||
// selected), which is why the plan-level TimestampColumn is null for this model by design.
|
||
timestampColumn = member.TimestampColumn;
|
||
row = outcome.Rows.Count > 0 ? outcome.Rows[^1] : null;
|
||
}
|
||
|
||
// No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell.
|
||
if (row is null) return new DataValueSnapshot(null, SqlStatusCodes.BadNoData, null, readAt);
|
||
|
||
// A timestamp that will not parse falls back to the poll clock rather than poisoning a good value.
|
||
var sourceTimestamp = ReadTimestamp(outcome, row, timestampColumn) ?? readAt;
|
||
|
||
if (valueColumn is null || !outcome.ColumnIndex.TryGetValue(valueColumn, out var valueAt))
|
||
{
|
||
// The planner selects every member's value column, so this is unreachable short of plan/result
|
||
// drift — loud rather than silently publishing someone else's cell.
|
||
_logger.LogError(
|
||
"Sql tag '{Tag}' reads column '{Column}', which the executed plan did not select.",
|
||
member.Name, valueColumn);
|
||
return new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, sourceTimestamp, readAt);
|
||
}
|
||
|
||
var cell = row[valueAt];
|
||
if (cell is null)
|
||
{
|
||
return new DataValueSnapshot(
|
||
null, _nullIsBad ? SqlStatusCodes.Bad : SqlStatusCodes.Uncertain, sourceTimestamp, readAt);
|
||
}
|
||
|
||
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
|
||
if (!TryCoerce(cell, target, out var value))
|
||
{
|
||
_logger.LogWarning(
|
||
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
|
||
member.Name, cell.GetType().Name, target);
|
||
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
|
||
}
|
||
|
||
return new DataValueSnapshot(value, SqlStatusCodes.Good, sourceTimestamp, readAt);
|
||
}
|
||
|
||
/// <summary>Reads a row's source timestamp, or null when there is no column, no value, or no parse.</summary>
|
||
private static DateTime? ReadTimestamp(GroupResult outcome, object?[] row, string? timestampColumn)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(timestampColumn)) return null;
|
||
if (!outcome.ColumnIndex.TryGetValue(timestampColumn, out var at)) return null;
|
||
if (row[at] is not { } cell) return null;
|
||
return TryCoerce(cell, DriverDataType.DateTime, out var value) ? (DateTime?)value : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
|
||
/// type matches the type the address space declared for the node. Never throws.
|
||
/// </summary>
|
||
private static bool TryCoerce(object cell, DriverDataType target, out object? value)
|
||
{
|
||
try
|
||
{
|
||
value = target switch
|
||
{
|
||
DriverDataType.Boolean => Convert.ToBoolean(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.Int16 => Convert.ToInt16(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.Int32 => Convert.ToInt32(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.Int64 => Convert.ToInt64(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.UInt16 => Convert.ToUInt16(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.UInt32 => Convert.ToUInt32(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.UInt64 => Convert.ToUInt64(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.Float32 => Convert.ToSingle(cell, CultureInfo.InvariantCulture),
|
||
// decimal/numeric collapse here, with the documented precision caveat (design §8.6).
|
||
DriverDataType.Float64 => Convert.ToDouble(cell, CultureInfo.InvariantCulture),
|
||
DriverDataType.DateTime => ToUtc(cell),
|
||
_ => Convert.ToString(cell, CultureInfo.InvariantCulture) ?? string.Empty,
|
||
};
|
||
return true;
|
||
}
|
||
catch (Exception ex) when (ex is InvalidCastException or FormatException
|
||
or OverflowException or ArgumentException)
|
||
{
|
||
value = null;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normalises a timestamp cell to UTC. A naive <c>datetime</c> (no offset — the common SQL Server
|
||
/// shape) is <b>assumed</b> to already be UTC rather than reinterpreted through the server's local
|
||
/// zone, which would silently shift every source timestamp by the host's offset.
|
||
/// </summary>
|
||
private static DateTime ToUtc(object cell) => cell switch
|
||
{
|
||
DateTime { Kind: DateTimeKind.Utc } utc => utc,
|
||
DateTime { Kind: DateTimeKind.Local } local => local.ToUniversalTime(),
|
||
DateTime unspecified => DateTime.SpecifyKind(unspecified, DateTimeKind.Utc),
|
||
DateTimeOffset offset => offset.UtcDateTime,
|
||
string text => DateTime.Parse(
|
||
text, CultureInfo.InvariantCulture,
|
||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
|
||
_ => DateTime.SpecifyKind(
|
||
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
|
||
};
|
||
|
||
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
|
||
private bool ShouldWarnAboutContract()
|
||
{
|
||
var now = Stopwatch.GetTimestamp();
|
||
var last = Interlocked.Read(ref _lastContractWarningTicks);
|
||
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
|
||
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
|
||
}
|
||
|
||
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
|
||
private sealed class GroupResult
|
||
{
|
||
private static readonly IReadOnlyList<object?[]> NoRows = [];
|
||
private static readonly IReadOnlyDictionary<string, int> NoColumns =
|
||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||
|
||
private GroupResult(
|
||
uint failureStatus,
|
||
IReadOnlyList<object?[]> rows,
|
||
IReadOnlyDictionary<string, int> columnIndex,
|
||
IReadOnlyList<string> typeNames)
|
||
{
|
||
FailureStatus = failureStatus;
|
||
Rows = rows;
|
||
ColumnIndex = columnIndex;
|
||
TypeNames = typeNames;
|
||
}
|
||
|
||
/// <summary>The group exceeded its wall-clock deadline (design §8.3).</summary>
|
||
public static GroupResult TimedOut { get; } =
|
||
new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []);
|
||
|
||
/// <summary>The connection was fine but the query was not.</summary>
|
||
public static GroupResult Failed { get; } =
|
||
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
|
||
|
||
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
|
||
public uint FailureStatus { get; }
|
||
|
||
/// <summary>The materialised rows, projected onto the plan's selected columns.</summary>
|
||
public IReadOnlyList<object?[]> Rows { get; }
|
||
|
||
/// <summary>Selected column name → its position within each row array.</summary>
|
||
public IReadOnlyDictionary<string, int> ColumnIndex { get; }
|
||
|
||
/// <summary>Each selected column's provider type name, for dialect type inference.</summary>
|
||
public IReadOnlyList<string> TypeNames { get; }
|
||
|
||
/// <summary>True when the query ran; a zero-row result set is still a success.</summary>
|
||
public bool Succeeded => FailureStatus == SqlStatusCodes.Good;
|
||
|
||
/// <summary>A completed result set.</summary>
|
||
/// <param name="rows">The materialised rows.</param>
|
||
/// <param name="columnIndex">Selected column name → row-array position.</param>
|
||
/// <param name="typeNames">Each selected column's provider type name.</param>
|
||
/// <returns>A successful outcome.</returns>
|
||
public static GroupResult Ok(
|
||
IReadOnlyList<object?[]> rows,
|
||
IReadOnlyDictionary<string, int> columnIndex,
|
||
IReadOnlyList<string> typeNames)
|
||
=> new(SqlStatusCodes.Good, rows, columnIndex, typeNames);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
|
||
/// them back with.
|
||
/// <para><b>Why a driver-local table rather than a shared one.</b> Every driver in this tree carries its
|
||
/// own (<c>TwinCATStatusMapper</c>, <c>AbCipStatusMapper</c>, <c>FocasStatusMapper</c>,
|
||
/// <c>AbLegacyStatusMapper</c>, Galaxy's <c>StatusCodeMap</c>) — there is no shared helper in
|
||
/// <c>Core.Abstractions</c>, because drivers deliberately do not reference the OPC UA SDK and
|
||
/// <see cref="DataValueSnapshot.StatusCode"/> is a bare <see cref="uint"/>. The values below were read
|
||
/// off <c>Opc.Ua.StatusCodes</c> rather than copied from a sibling driver, since two of those tables
|
||
/// carry transcription errors.</para>
|
||
/// </summary>
|
||
public static class SqlStatusCodes
|
||
{
|
||
/// <summary>The value is good. (<c>Good</c>)</summary>
|
||
public const uint Good = 0x00000000u;
|
||
|
||
/// <summary>Quality is uncertain with no more specific reason — a NULL cell under <c>nullIsBad=false</c>.</summary>
|
||
public const uint Uncertain = 0x40000000u;
|
||
|
||
/// <summary>Quality is bad with no more specific reason — a NULL cell under <c>nullIsBad=true</c>.</summary>
|
||
public const uint Bad = 0x80000000u;
|
||
|
||
/// <summary>
|
||
/// No row was returned for this tag — the key is absent from the result set, or the wide-row
|
||
/// selector matched nothing. <b>Deliberately distinct from a NULL cell:</b> the row is gone, versus
|
||
/// the row is there and the value is not.
|
||
/// </summary>
|
||
public const uint BadNoData = 0x809B0000u;
|
||
|
||
/// <summary>The group exceeded its client-side wall-clock deadline (design §8.3).</summary>
|
||
public const uint BadTimeout = 0x800A0000u;
|
||
|
||
/// <summary>The reference resolves to no authored tag.</summary>
|
||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||
|
||
/// <summary>The query failed after the connection opened.</summary>
|
||
public const uint BadCommunicationError = 0x80050000u;
|
||
|
||
/// <summary>The cell could not be coerced to the tag's effective data type.</summary>
|
||
public const uint BadTypeMismatch = 0x80740000u;
|
||
|
||
/// <summary>A tag definition the planner rejected — an authoring fault, not a database fault.</summary>
|
||
public const uint BadConfigurationError = 0x80890000u;
|
||
|
||
/// <summary>A defect in the reader itself; never expected in the field.</summary>
|
||
public const uint BadInternalError = 0x80020000u;
|
||
|
||
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
|
||
private const uint SeverityMask = 0xC0000000u;
|
||
|
||
/// <summary>True when <paramref name="statusCode"/> is in the Good quality class.</summary>
|
||
/// <param name="statusCode">The status code to classify.</param>
|
||
/// <returns>True when the code's severity bits are Good.</returns>
|
||
public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u;
|
||
|
||
/// <summary>True when <paramref name="statusCode"/> is in the Uncertain quality class.</summary>
|
||
/// <param name="statusCode">The status code to classify.</param>
|
||
/// <returns>True when the code's severity bits are Uncertain.</returns>
|
||
public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain;
|
||
|
||
/// <summary>True when <paramref name="statusCode"/> is in the Bad quality class.</summary>
|
||
/// <param name="statusCode">The status code to classify.</param>
|
||
/// <returns>True when the code's severity bits are Bad.</returns>
|
||
public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad;
|
||
}
|