diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
new file mode 100644
index 00000000..2c4155ea
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
@@ -0,0 +1,770 @@
+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.
+ ///
+ /// 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.
+ /// 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)
+ {
+ _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;
+ }
+
+ ///
+ /// 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