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
@@ -177,6 +177,31 @@ public interface ISqlDialect
/// </summary>
string SingleRowLimitSuffix { get; }
/// <summary>
/// The <see cref="SingleRowLimitPrefix"/> position's fragment for an <paramref name="rows"/>-row limit —
/// T-SQL's <c>"TOP (n) "</c>; empty for a dialect that limits at the end. Same trailing-space rule.
/// </summary>
/// <remarks>
/// 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 <c>N + 1</c> 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.
/// </remarks>
/// <param name="rows">The row limit; at least 1.</param>
/// <returns>The fragment, possibly empty.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
string RowLimitPrefix(int rows);
/// <summary>
/// The <see cref="SingleRowLimitSuffix"/> position's fragment for an <paramref name="rows"/>-row limit —
/// <c>" LIMIT n"</c> / <c>" FETCH FIRST n ROWS ONLY"</c>; empty for a dialect that limits after
/// <c>SELECT</c>. Same leading-space rule. See <see cref="RowLimitPrefix"/>.
/// </summary>
/// <param name="rows">The row limit; at least 1.</param>
/// <returns>The fragment, possibly empty.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
string RowLimitSuffix(int rows);
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; }
@@ -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";
/// <inheritdoc />
public string RowLimitPrefix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc />
// 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";
}
/// <inheritdoc />
// 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.
@@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect
/// <inheritdoc />
public string SingleRowLimitSuffix => " LIMIT 1";
/// <inheritdoc />
public string RowLimitPrefix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc />
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
@@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect
/// <inheritdoc />
public string SingleRowLimitSuffix => string.Empty;
/// <inheritdoc />
// 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) + ") ";
}
/// <inheritdoc />
public string RowLimitSuffix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc/>
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";