fix(sql): attribute a duplicated key to its own tags; key-value cap gets 2x headroom (R06:S-1 review I-1, m-3)

One stray duplicate row used to fail every tag in a key-value group or none of them,
depending on whether an unrelated key was absent. Duplicated keys are now refused
per key: only that key's tags publish BadConfigurationError and the rest of the group
reads normally. The key-value cap reads up to 2x the distinct keys (+1 to detect
excess), so a few stray duplicates are attributed per key and only a gross excess
(history/events table) fails the whole group. Duplicated-key refusals count as
answered refusals for health. The row-cap, duplicate-key and multi-row warnings now
use the per-plan limiter (m-3); the reader-wide limiter is removed. Unit tests pin
both the all-keys-present and absent-key cases with Good controls; live SQL Server
gate added (23/23).

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:32:23 -04:00
parent 8f0046ed05
commit c1b9c7ec5c
8 changed files with 206 additions and 85 deletions
@@ -56,11 +56,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// 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).
/// The most rows the reader will attribute before refusing the whole group — twice the distinct key count
/// (key-value: headroom so stray duplicates are attributed per key) or 1 (a where-pair wide row: one
/// selector, so any second row is the group's ambiguity); <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 means a history/events table or 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,