diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md
index 7b7ad3ae..ffa68922 100644
--- a/docs/drivers/Sql.md
+++ b/docs/drivers/Sql.md
@@ -166,6 +166,17 @@ emits a `SqlQueryPlan` per distinct group key. Grouping is deterministic (groups
order of their first member, members keep their input order, parameters keep first-appearance order),
so an unchanged configuration produces byte-identical SQL.
+**Every key-value and where-pair query is row-capped (R06:S-1).** A key-value plan asks for one row more
+than it has distinct keys (`TOP (n+1)` / `LIMIT n+1` / `FETCH FIRST n+1 ROWS ONLY`), a where-pair wide-row plan
+for two, and the reader stops reading there even if a provider ignores the limit. Receiving the extra row
+proves the selector is ambiguous — a history/events table, or a where column that is not unique — so every
+member of that group publishes `BadConfigurationError` with a rate-limited warning naming the table and
+selector, instead of the old behaviour of materialising every matching row on every poll and publishing an
+arbitrary "last row" at Good. A duplicate that fits inside the cap (a duplicated key alongside an absent one)
+still resolves last-row-wins. `topByTimestamp` keeps its single-row limit. An over-cap answer, like every
+other refusal (absent row, NULL cell, uncoercible cell), is the database answering: it advances
+`LastSuccessfulRead` and restores `Healthy` after a comms fault.
+
⚠️ **Group keys are case-sensitive on identifiers.** Two tags authored `dbo.T` and `DBO.T` plan as
**two** groups — one extra round-trip, not a correctness problem, but worth knowing if a group count
looks higher than expected.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index c144ec62..e1069372 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -177,6 +177,31 @@ public interface ISqlDialect
///
string SingleRowLimitSuffix { get; }
+ ///
+ /// The position's fragment for an -row limit —
+ /// T-SQL's "TOP (n) "; empty for a dialect that limits at the end. Same trailing-space rule.
+ ///
+ ///
+ /// Added for the key-value / where-pair row cap (R06:S-1): those plans used to carry no limit, so an
+ /// ambiguous selector against a history table materialised every matching row on every poll. The
+ /// planner asks for N + 1 rows — one more than the plan can use — so ambiguity stays observable
+ /// (the reader sees the extra row and publishes a configuration error) without unbounded reads.
+ ///
+ /// The row limit; at least 1.
+ /// The fragment, possibly empty.
+ /// is below 1.
+ string RowLimitPrefix(int rows);
+
+ ///
+ /// The position's fragment for an -row limit —
+ /// " LIMIT n" / " FETCH FIRST n ROWS ONLY"; empty for a dialect that limits after
+ /// SELECT. Same leading-space rule. See .
+ ///
+ /// The row limit; at least 1.
+ /// The fragment, possibly empty.
+ /// is below 1.
+ string RowLimitSuffix(int rows);
+
/// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters.
string ListSchemasSql { get; }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
index 2dc72a83..1789959f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
@@ -124,6 +124,21 @@ public sealed class OracleDialect : ISqlDialect
// statement shape, which this seam deliberately leaves with the planner.
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
+ ///
+ public string RowLimitPrefix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return string.Empty;
+ }
+
+ ///
+ // The same 12c+ row-limiting clause as SingleRowLimitSuffix; valid after a WHERE with no ORDER BY.
+ public string RowLimitSuffix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return " FETCH FIRST " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ROWS ONLY";
+ }
+
///
// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
index b332d912..6809b328 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
@@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect
///
public string SingleRowLimitSuffix => " LIMIT 1";
+ ///
+ public string RowLimitPrefix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return string.Empty;
+ }
+
+ ///
+ public string RowLimitSuffix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ }
+
// The schemas holding relations this connection can actually see, system schemas excluded.
//
// Sourced from information_schema.tables, not information_schema.schemata - a deliberate
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
index 9db9848c..61057407 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
@@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect
///
public string SingleRowLimitSuffix => string.Empty;
+ ///
+ // Parenthesised: TOP (n) is the documented modern form, and TOP n without parentheses is the
+ // backwards-compatibility spelling.
+ public string RowLimitPrefix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return "TOP (" + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + ") ";
+ }
+
+ ///
+ public string RowLimitSuffix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return string.Empty;
+ }
+
///
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
index 945fc32b..4a8936c9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -519,9 +519,9 @@ public sealed class SqlDriver
try
{
- var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
- ObservePollOutcome(snapshots);
- return snapshots;
+ var result = await _reader.ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false);
+ ObservePollOutcome(result.Snapshots, result.RowCapExceededGroups);
+ return result.Snapshots;
}
catch (OperationCanceledException)
{
@@ -608,7 +608,13 @@ public sealed class SqlDriver
/// cadence rather than backed off from.
///
/// The snapshots the reader returned for one poll.
- internal void ObservePollOutcome(IReadOnlyList snapshots)
+ ///
+ /// Groups the database answered but the driver refused (an over-cap result set, R06:S-1). Their
+ /// members carry , which alone is not answer evidence
+ /// (the planner mints it before any I/O), so the count is what makes them count as the database
+ /// answering.
+ ///
+ internal void ObservePollOutcome(IReadOnlyList snapshots, int answeredRefusals = 0)
{
if (snapshots.Count == 0) return;
@@ -625,6 +631,8 @@ public sealed class SqlDriver
if (IsAnswerEvidence(snapshot.StatusCode)) answered++;
}
+ answered += answeredRefusals;
+
if (unreachable > 0)
{
Degrade(
@@ -660,17 +668,12 @@ public sealed class SqlDriver
if (answered == 0) return; // authoring-class Bad only — not a statement about the database.
- if (good == 0)
- {
- // The database answered, just not with a value: an absent row, a NULL cell, a cell that would
- // not coerce. Those are data/authoring facts, so the health STATE is deliberately left exactly
- // as it was — but they are still proof the transport works, and the evidence clock the
- // comms-loss watchdog reads must say so.
- TouchLastSuccessfulRead();
- TransitionHostTo(HostState.Running);
- return;
- }
+ // Good values, OR a database that answered with refusals only — an absent row, a NULL cell, an
+ // uncoercible cell, an over-cap result set. A refusal is the database answering: it advances the
+ // evidence clock AND restores Healthy after a comms fault, exactly as a Good read does. Leaving the
+ // state as it was latched a recovered database Degraded behind every poll that read only refusals.
+ //
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
index 9d1142b8..31c77351 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
@@ -230,15 +230,21 @@ public static class SqlGroupPlanner
values.Add(keyValue);
}
+ // Row cap (R06:S-1): one row per distinct key is all this plan can use, so it asks for ONE more — enough
+ // to see a source breaking the one-row-per-key contract, never enough to pull a history table's every
+ // matching row into memory on every poll.
+ var rowCap = values.Count;
var sql = new StringBuilder("SELECT ")
+ .Append(dialect.RowLimitPrefix(rowCap + 1))
.Append(QuoteList(selected, dialect))
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
.Append(" IN (").AppendJoin(", ", markers).Append(')')
+ .Append(dialect.RowLimitSuffix(rowCap + 1))
.ToString();
return new SqlQueryPlan(
- SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
+ SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected, rowCap);
}
///
@@ -270,14 +276,18 @@ public static class SqlGroupPlanner
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
{
+ // Capped at TWO rows (R06:S-1): the selector must identify one row, and the second is the proof it
+ // does not. A TOP 1 alone would hide that ambiguity (see SqlPollReader.SelectWideRow); no cap at all
+ // materialised every matching row of an events table on every poll.
var marker = dialect.ParameterMarker(RowSelectorBaseName);
var sql = string.Concat(
- "SELECT ", columns, " FROM ", table,
- " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
+ "SELECT ", dialect.RowLimitPrefix(2), columns, " FROM ", table,
+ " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker,
+ dialect.RowLimitSuffix(2));
return new SqlQueryPlan(
SqlTagModel.WideRow, groupKey, sql,
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
- [first.RowSelectorValue], members, selected);
+ [first.RowSelectorValue], members, selected, RowCap: 1);
}
if (!Blank(first.RowSelectorTopByTimestamp))
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
index da6ecfb5..25e70a39 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
@@ -162,11 +162,24 @@ public sealed class SqlPollReader : IDisposable
/// The database could not be reached (opening a connection failed).
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ => (await ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false)).Snapshots;
+
+ ///
+ /// plus what the snapshots alone cannot say: how many groups the database
+ /// ANSWERED with more rows than the plan can use (R06:S-1). Those groups publish
+ /// — the same code an un-plannable definition gets
+ /// before any I/O — so the driver needs this count to treat them as the answer evidence they are.
+ ///
+ /// The RawPaths to read.
+ /// The caller's token; see .
+ /// The snapshots and the answered-refusal count.
+ public async Task ReadDetailedAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
- if (results.Length == 0) return results;
+ if (results.Length == 0) return new SqlPollResult(results, 0);
var readAt = DateTime.UtcNow;
@@ -189,19 +202,23 @@ public sealed class SqlPollReader : IDisposable
positions.Add(i);
}
- if (definitions.Count > 0)
- await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
+ var rowCapExceeded = definitions.Count > 0
+ ? await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false)
+ : 0;
// 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;
+ return new SqlPollResult(results, rowCapExceeded);
}
- /// Plans the resolved definitions, runs every group concurrently, and slices the results back.
- private async Task ReadGroupsAsync(
+ ///
+ /// Plans the resolved definitions, runs every group concurrently, and slices the results back.
+ /// Returns how many groups came back over their row cap.
+ ///
+ private async Task ReadGroupsAsync(
List definitions,
Dictionary> slots,
DataValueSnapshot[] results,
@@ -227,7 +244,7 @@ public sealed class SqlPollReader : IDisposable
foreach (var position in positions) results[position] = snapshot;
}
- return;
+ return 0;
}
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
@@ -241,8 +258,14 @@ public sealed class SqlPollReader : IDisposable
// leaves a sibling group running against a connection nobody is watching.
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
+ var rowCapExceeded = 0;
for (var g = 0; g < plans.Count; g++)
+ {
Slice(plans[g], outcomes[g], slots, results, readAt);
+ if (ReferenceEquals(outcomes[g], GroupResult.RowCapExceeded)) rowCapExceeded++;
+ }
+
+ return rowCapExceeded;
}
///
@@ -502,8 +525,11 @@ public sealed class SqlPollReader : IDisposable
columnIndex[selected[c]] = c;
}
+ // The client-side half of the row cap (R06:S-1): the statement already asks for RowCap + 1 rows, but a
+ // provider that cannot spell a limit must not turn that into an unbounded read either.
+ var limit = plan.RowCap is { } cap ? cap + 1 : int.MaxValue;
var rows = new List
public string SingleRowLimitSuffix => " LIMIT 1";
+ ///
+ public string RowLimitPrefix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return string.Empty;
+ }
+
+ ///
+ public string RowLimitSuffix(int rows)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
+ return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ }
+
///
/// SQLite has no schema namespace, so the schema level is the single literal
/// — enough to give the browser a real root to expand.