fix(sql): row-cap key-value and where-pair plans with a TOP/LIMIT N+1 dialect seam (R06:S-1)

Key-value and where-pair plans had no row limit, so a selector pointed at a history
or events table materialised every matching row on every poll. ISqlDialect gains
RowLimitPrefix/RowLimitSuffix(n) (SQL Server TOP (n), Postgres LIMIT n, Oracle FETCH
FIRST n ROWS ONLY); plans carry a RowCap (distinct keys, or 1) and ask for RowCap+1
rows; the reader also stops client-side at RowCap+1. Getting the extra row proves the
selector ambiguous: the group publishes BadConfigurationError with a rate-limited
warning. The reader reports those answered refusals so the driver counts them as
answer evidence, and any refusal-only poll now restores Healthy after a comms fault.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:57:13 -04:00
parent 7f5d959fb9
commit 0a5aa9fc43
21 changed files with 420 additions and 54 deletions
@@ -55,6 +55,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
/// reader's map from result-set ordinal to source column.
/// </param>
/// <param name="RowCap">
/// The most rows this plan can USE — its distinct key count (key-value) or 1 (a where-pair wide row);
/// <see langword="null"/> when the statement already limits itself (topByTimestamp). <see cref="SqlText"/>
/// asks for <c>RowCap + 1</c>, and the reader stops there: receiving the extra row proves the selector is
/// ambiguous (a history table, a non-unique where column) and the group publishes
/// <c>BadConfigurationError</c> instead of materialising every matching row on every poll (R06:S-1).
/// </param>
public sealed record SqlQueryPlan(
SqlTagModel Model,
object GroupKey,
@@ -63,7 +70,8 @@ public sealed record SqlQueryPlan(
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
IReadOnlyList<string> SelectedColumns,
int? RowCap = null)
{
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a