Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerChunkingTests.cs
T
Joseph Doherty 0a5aa9fc43 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
2026-10-22 15:57:13 -04:00

323 lines
14 KiB
C#

using System.Data.Common;
using System.Globalization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Pins the bind-parameter ceiling (Driver.Sql-002 / Driver.Sql.Dialect-002): a key-value group holding
/// more distinct <c>keyValue</c>s than the dialect's <see cref="ISqlDialect.MaxBindParameters"/> is split
/// into several plans over the <b>same</b> group key, rather than emitting one statement the provider
/// rejects.
/// <para><b>Why this is a correctness test and not a tuning one.</b> Over the ceiling the provider refuses
/// the statement <em>after</em> the connection opened, so every tag in the group publishes
/// <c>BadCommunicationError</c> on every poll, permanently — and a key-value (EAV) staging table is
/// precisely the shape that attracts thousands of tags on one table, which the group-key rule folds into
/// one group.</para>
/// <para>A single group emitting several plans needs <b>no reader change</b>: <c>SqlPollReader</c> runs
/// one command per plan and slices each plan's own <c>Members</c> back, so the last test here drives the
/// whole read path over a real database to prove exactly that.</para>
/// </summary>
public sealed class SqlGroupPlannerChunkingTests
{
private static SqlTagDefinition KvTag(string name, string keyValue, string table = "dbo.TagValues")
=> new(name, SqlTagModel.KeyValue, table,
KeyColumn: "tag_name", KeyValue: keyValue, ValueColumn: "num_value");
// ---- the shipped ceilings ----
/// <summary>
/// Oracle's real ceiling, on the real dialect: an <c>IN</c> expression list may hold 1000 entries, so
/// 1001 distinct keys cannot be one statement. This is the shape the finding describes — no test-only
/// dialect involved.
/// </summary>
[Fact]
public void AGroupBeyondTheOracleInListLimit_isSplit_ratherThanEmittedAsOneRejectedStatement()
{
var dialect = new OracleDialect();
var tags = Enumerable.Range(0, 1001)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, dialect);
plans.Count.ShouldBe(2);
plans.ShouldAllBe(p => p.Parameters.Count <= 1000);
plans.Sum(p => p.Parameters.Count).ShouldBe(1001);
}
/// <summary>
/// The same for SQL Server, whose 2100-parameter command cap is the other limit the finding names.
/// </summary>
[Fact]
public void AGroupBeyondTheSqlServerParameterCap_isSplit()
{
var dialect = new SqlServerDialect();
var tags = Enumerable.Range(0, dialect.MaxBindParameters + 1)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, dialect);
plans.Count.ShouldBe(2);
plans.ShouldAllBe(p => p.Parameters.Count <= dialect.MaxBindParameters);
}
// ---- how a split group is shaped ----
[Fact]
public void EachChunkBindsAtMostTheCeiling_andTheChunksCoverEveryKeyExactlyOnce()
{
var tags = Enumerable.Range(0, 7)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(3));
plans.Count.ShouldBe(3);
plans.Select(p => p.Parameters.Count).ShouldBe(new[] { 3, 3, 1 });
plans.SelectMany(p => p.Parameters)
.ShouldBe(Enumerable.Range(0, 7).Select(i => (object?)("Key" + i.ToString(CultureInfo.InvariantCulture))));
}
/// <summary>
/// Every member must land in the chunk that binds <em>its own</em> key — a member whose key is bound
/// by a different plan reads a result set that cannot contain its row and publishes
/// <c>BadNoData</c> forever, which is the quiet way to get this wrong.
/// </summary>
[Fact]
public void EveryMemberLandsInTheChunkThatBindsItsOwnKey()
{
var tags = Enumerable.Range(0, 7)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(3));
foreach (var plan in plans)
foreach (var member in plan.Members)
plan.Parameters.ShouldContain(member.KeyValue);
// …and no member is dropped or duplicated across the split.
plans.SelectMany(p => p.Members).Select(m => m.Name)
.ShouldBe(tags.Select(t => t.Name), ignoreOrder: true);
plans.Sum(p => p.Members.Count).ShouldBe(tags.Length);
}
/// <summary>
/// Two tags on one <c>keyValue</c> bind one parameter and must stay in one chunk — splitting them
/// would bind the same key twice and cost a whole extra round-trip for a row already fetched.
/// </summary>
[Fact]
public void MembersSharingAKeyValue_stayInTheSameChunk()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "K1"), KvTag("B", "K2"), KvTag("C", "K1"), KvTag("D", "K3"),
], new BindCeilingDialect(2));
plans.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "K1", "K2" });
plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" });
plans[1].Parameters.ShouldBe(new object[] { "K3" });
plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "D" });
}
/// <summary>
/// Each chunk is its own command, so its markers restart at <c>k0</c> — carrying the global index
/// forward would emit <c>IN (@k3, @k4, @k5)</c> against a three-parameter command, which is legal but
/// makes a plan's SQL unreadable next to its own parameter list.
/// </summary>
[Fact]
public void EachChunkNumbersItsMarkersFromZero_andTheSqlMatchesItsOwnParameters()
{
var tags = Enumerable.Range(0, 5)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(2));
foreach (var plan in plans)
{
plan.ParameterNames.ShouldBe(
Enumerable.Range(0, plan.Parameters.Count)
.Select(i => "@k" + i.ToString(CultureInfo.InvariantCulture)));
plan.SqlText.ShouldContain("IN (" + string.Join(", ", plan.ParameterNames) + ")");
}
}
/// <summary>All chunks of one group carry that group's key — the split is an execution detail, not a regrouping.</summary>
[Fact]
public void EveryChunkOfOneGroupCarriesTheSameGroupKey()
{
var tags = Enumerable.Range(0, 5)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(2));
plans.Count.ShouldBeGreaterThan(1);
plans.Select(p => p.GroupKey).Distinct().Count().ShouldBe(1);
}
/// <summary>
/// The falsifiability control. Chunking must not fragment an ordinary group: a planner that split
/// eagerly (or off a stale count) would pass every test above and quietly turn one round-trip per
/// source into N.
/// </summary>
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void AGroupAtOrUnderTheCeiling_stillEmitsExactlyOnePlan(int distinctKeys)
{
var tags = Enumerable.Range(0, distinctKeys)
.Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture),
"Key" + i.ToString(CultureInfo.InvariantCulture)))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(3));
plans.Count.ShouldBe(1);
plans[0].Members.Count.ShouldBe(distinctKeys);
}
/// <summary>
/// Two groups that each need splitting stay separate, and the chunks of one never mix with the other's
/// — the split happens inside a group, after grouping.
/// </summary>
[Fact]
public void TwoOversizedGroups_areSplitIndependently()
{
var tags = Enumerable.Range(0, 3).Select(i => KvTag("A" + i, "K" + i, "dbo.First"))
.Concat(Enumerable.Range(0, 3).Select(i => KvTag("B" + i, "K" + i, "dbo.Second")))
.ToArray();
var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(2));
plans.Count.ShouldBe(4);
// Group order still follows the input order of each group's first member (dbo.First, then dbo.Second),
// and a group's own chunks stay adjacent and in key order.
plans.Select(p => p.Members[0].Table).ShouldBe(["dbo.First", "dbo.First", "dbo.Second", "dbo.Second"]);
}
/// <summary>A wide-row plan binds at most one value, so a ceiling of 1 must not disturb it.</summary>
[Fact]
public void AWideRowPlan_isUnaffectedByTheCeiling()
{
var tag = new SqlTagDefinition("A", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "oven_temp", RowSelectorColumn: "station_id", RowSelectorValue: "7");
var plans = SqlGroupPlanner.Plan([tag], new BindCeilingDialect(1));
plans.Count.ShouldBe(1);
plans[0].Parameters.Count.ShouldBe(1);
}
/// <summary>
/// A dialect reporting a ceiling below 1 cannot be chunked to — it must fail loudly rather than loop
/// forever or emit an empty <c>IN ()</c> list, which is a syntax error on every backend.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void ADialectReportingANonPositiveCeiling_isRejected(int ceiling)
=> Should.Throw<ArgumentException>(
() => SqlGroupPlanner.Plan([KvTag("A", "K1")], new BindCeilingDialect(ceiling)));
// ---- the whole read path, over a real database ----
/// <summary>
/// Drives <see cref="SqlPollReader"/> over the SQLite fixture with a ceiling of 1, so three keys become
/// three plans, and asserts every tag still reads its own value. This is what proves several plans per
/// group need <b>no reader change</b> — the claim the chunking design rests on.
/// </summary>
[Fact]
public async Task ThePollReader_readsEveryTagCorrectly_whenOneGroupIsSplitAcrossSeveralPlans()
{
using var fixture = new SqlitePollFixture();
SqlTagDefinition[] tags =
[
Fixture("Speed", SqlitePollFixture.PresentKey),
Fixture("Pressure", SqlitePollFixture.SecondPresentKey),
Fixture("Missing", SqlitePollFixture.AbsentKey),
];
var reader = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new BindCeilingDialect(1),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: reference => tags.FirstOrDefault(t => t.Name == reference));
var snapshots = await reader.ReadAsync(["Speed", "Pressure", "Missing"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
}
private static SqlTagDefinition Fixture(string name, string keyValue)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
/// <summary>
/// A <b>test-only</b> dialect that reports a caller-chosen <see cref="ISqlDialect.MaxBindParameters"/>
/// and delegates everything else to <see cref="SqliteDialect"/> — so a chunked plan can be asserted on
/// at a readable size <em>and</em> actually executed against the fixture database.
/// <para>A decorator rather than a subclass, for the reason the other fakes here give:
/// <see cref="SqliteDialect"/> is sealed, and <c>new</c>-hiding a member would leave interface dispatch
/// calling the original, so the substitution would silently not happen.</para>
/// </summary>
private sealed class BindCeilingDialect(int maxBindParameters) : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
public int MaxBindParameters { get; } = maxBindParameters;
public SqlProvider Provider => Inner.Provider;
public DbProviderFactory Factory => Inner.Factory;
public string LivenessSql => Inner.LivenessSql;
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
public string ListSchemasSql => Inner.ListSchemasSql;
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
public string ListTablesSql => Inner.ListTablesSql;
public string ListColumnsSql => Inner.ListColumnsSql;
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
public void ConfigureCommand(DbCommand command) => Inner.ConfigureCommand(command);
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
}
}