Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs
T
Joseph Doherty e3e3f5fb04 fix(sql): report an ambiguous wide-row selector; pin the zombie-slot bound
Three review findings against SqlPollReader (967e5140).

1. WideRow's where-pair form emits no ORDER BY and no row limit, so a
   selector that is not actually unique made the reader publish an
   arbitrary, storage-order-dependent row — silently, unlike IndexByKey's
   duplicate-key warning. Row selection moves out of MapMember into a new
   SelectWideRow, which is called once per group and emits the same
   rate-limited contract warning (naming table, selector and row count).

   The row-limit half of the suggested fix was deliberately NOT taken: a
   TOP 1 with no ORDER BY is not deterministic either, and it would
   destroy the Rows.Count > 1 evidence the warning depends on, turning a
   loud misconfiguration into a permanently silent one. Rationale is in
   SelectWideRow's docs. No SQL changed, so no planner golden string moved.

2. The "a frozen database can never accumulate more than
   maxConcurrentGroups connections" claim was asserted in the docs and
   untested. ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQuery-
   TrulyFinishes wedges a group behind an EXCLUSIVE transaction, times it
   out, and proves the slot is still held (a second poll on a cap of 1
   times out on the gate without ever reaching the factory) and is handed
   back only once the abandoned query truly finishes. Moving the release
   from the work to the waiter fails this test and no other.

3. Task.Run thread-pool starvation: documented, not changed.
   Microsoft.Data.SqlClient's async path parks no pool thread on a frozen
   server, so the driver cannot starve itself; LongRunning would cost a
   dedicated OS thread per group per poll forever to insure against a
   provider this driver does not ship. RunGroupAsync now records the
   decision and what a BadTimeout does and does not mean, for the
   blackhole gate.

Also: the timeout warning now names the group's table, so a BadTimeout
storm identifies which source is frozen.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:57:14 -04:00

652 lines
31 KiB
C#

using System.Data;
using System.Data.Common;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
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>
/// Proves <see cref="SqlPollReader"/> against a real database (<see cref="SqlitePollFixture"/>): the
/// N-in/N-out ordering contract, the slice-back of one result set to many tags, the three distinct
/// no-value outcomes (absent row / NULL cell / unresolvable ref), and — the part that cannot be proven
/// by reading the code — that a query which refuses to return <b>does not wedge the caller</b>.
/// <para><b>Each test owns its own fixture.</b> The fixture is a temp file, so a fresh one per test is
/// cheap and buys total isolation — which matters here because two tests deliberately mutate the
/// database (an extra duplicate row; an EXCLUSIVE transaction) in ways a shared fixture would leak into
/// every other test in the class.</para>
/// </summary>
public sealed class SqlPollReaderTests
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
// ---- the plan's headline contract: N in, N out, in input order ----
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Missing", SqlitePollFixture.AbsentKey),
KvTag("Temp", SqlitePollFixture.NullValueKey));
var snapshots = await reader.ReadAsync(["Speed", "Missing", "Temp"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
// [0] present row, present cell — REAL infers Float64, so the published CLR type is double.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
snapshots[0].SourceTimestampUtc.ShouldBe(
new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); // from sample_ts, not the poll clock
// [1] no row at all — NOT the same thing as a NULL cell.
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
snapshots[1].Value.ShouldBeNull();
// [2] row present, value cell NULL, nullIsBad = false ⇒ Uncertain (and the row's timestamp survives).
snapshots[2].Value.ShouldBeNull();
SqlStatusCodes.IsUncertain(snapshots[2].StatusCode).ShouldBeTrue();
snapshots[2].SourceTimestampUtc.ShouldBe(
new DateTime(2026, 7, 24, 10, 0, 1, DateTimeKind.Utc));
}
[Fact]
public async Task ReadAsync_withNullIsBad_publishesBadForANullCell_butStillBadNoDataForAnAbsentRow()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, nullIsBad: true,
tags: [KvTag("Temp", SqlitePollFixture.NullValueKey), KvTag("Missing", SqlitePollFixture.AbsentKey)]);
var snapshots = await reader.ReadAsync(["Temp", "Missing"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Bad);
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
// The nullIsBad switch governs a NULL cell only — an absent row keeps its own, more specific code.
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isBadNodeIdUnknown_andItsNeighboursStillRead()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
var snapshots = await reader.ReadAsync(
["Speed", "NotAuthored", "Pressure"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The hole must not shift the tail — this is the ordering contract's real failure mode.
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
}
[Fact]
public async Task ReadAsync_twoTagsSharingOneKeyValue_bothReceiveTheValue()
{
// SqlQueryPlan.Members keeps duplicates: these two tags bind ONE parameter but stay TWO members,
// and a reader that indexed members by key into a 1:1 map would feed only one of them.
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("SpeedA", SqlitePollFixture.PresentKey),
KvTag("SpeedB", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["SpeedA", "SpeedB"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
}
[Fact]
public async Task ReadAsync_theSameRefTwice_feedsBothSlots()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Speed", "Speed"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task ReadAsync_withNoRefs_returnsAnEmptyList_andOpensNoConnection()
{
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false, resolve: _ => null);
(await reader.ReadAsync([], CancellationToken.None)).ShouldBeEmpty();
factory.Created.ShouldBe(0);
}
// ---- wide row ----
[Fact]
public async Task ReadAsync_wideRow_slicesEachColumnToItsOwnMember()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("Pressure", "pressure", selectorValue: SqlitePollFixture.PresentStation));
var snapshots = await reader.ReadAsync(["Oven", "Pressure"], CancellationToken.None);
// One row, two tags — the wrong-slice defect would cross these two values over.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentStationPressure);
snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc));
}
[Fact]
public async Task ReadAsync_wideRow_absentRowIsBadNoData_andANullCellIsUncertain()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Gone", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation),
WideTag("NullOven", "oven_temp", selectorValue: SqlitePollFixture.NullOvenTempStation),
WideTag("NullOvenPressure", "pressure", selectorValue: SqlitePollFixture.NullOvenTempStation));
var snapshots = await reader.ReadAsync(
["Gone", "NullOven", "NullOvenPressure"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no row matched the selector
SqlStatusCodes.IsUncertain(snapshots[1].StatusCode).ShouldBeTrue(); // row matched, cell NULL
snapshots[1].Value.ShouldBeNull();
snapshots[2].Value.ShouldBe(1.6); // its neighbour on the SAME row is unaffected
}
[Fact]
public async Task ReadAsync_wideRow_topByTimestamp_readsTheNewestRow()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Newest"], CancellationToken.None);
// Station 8, not the first-inserted station 7 — a lost ORDER BY / row limit shows up here.
snapshots[0].Value.ShouldBe(SqlitePollFixture.NewestStationOvenTemp);
snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp);
}
[Fact]
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
{
using var fixture = new SqlitePollFixture();
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
// which of the two the reader publishes is decided by physical storage order — an authoring mistake
// (a selector column that is not actually unique) that must not pass silently. This is the WideRow
// counterpart of ReadAsync_whenASourceViolatesOneRowPerKey_...'s duplicate-key warning.
AddSecondRowForPresentStation(fixture);
var logger = new RecordingLogger();
var reader = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
var warning = logger.Entries.ShouldHaveSingleItem();
warning.Level.ShouldBe(LogLevel.Warning);
// Names the table AND the selector, so an operator can find the offending tag from the log alone.
warning.Message.ShouldContain(SqlitePollFixture.WideRowTable);
warning.Message.ShouldContain(SqlitePollFixture.WideRowSelectorColumn);
warning.Message.ShouldContain(SqlitePollFixture.PresentStation);
}
[Fact]
public async Task ReadAsync_wideRow_theAmbiguousSelectorWarningIsRateLimited_andSilentWhenUnambiguous()
{
using var fixture = new SqlitePollFixture();
var logger = new RecordingLogger();
// A selector that matches exactly one row is the normal case and must log nothing at all —
// without this leg a warning that always fires would still pass the test above.
var clean = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
await clean.ReadAsync(["Oven"], CancellationToken.None);
logger.Entries.ShouldBeEmpty();
AddSecondRowForPresentStation(fixture);
var ambiguous = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
// A poll loop hits this every cycle; the rate limit is what stops a misconfigured table flooding
// the log. Two polls, one warning.
logger.Entries.Count.ShouldBe(1);
}
// ---- type + timestamp mapping ----
[Fact]
public async Task ReadAsync_coercesTheCellToTheTagsDeclaredType()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("AsInt", SqlitePollFixture.PresentKey, DriverDataType.Int32),
KvTag("AsText", SqlitePollFixture.PresentKey, DriverDataType.String),
KvTag("Inferred", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["AsInt", "AsText", "Inferred"], CancellationToken.None);
snapshots[0].Value.ShouldBe(42); // int, not double — the declared type wins
snapshots[1].Value.ShouldBe("42"); // string
snapshots[2].Value.ShouldBe(42.0); // no declaration ⇒ dialect-inferred from REAL
}
[Fact]
public async Task ReadAsync_withNoTimestampColumn_stampsThePollClock()
{
using var fixture = new SqlitePollFixture();
var before = DateTime.UtcNow;
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey, timestampColumn: null));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].SourceTimestampUtc.ShouldNotBeNull();
snapshots[0].SourceTimestampUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
}
[Fact]
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
{
using var fixture = new SqlitePollFixture();
fixture.Execute(
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
// deterministic — last occurrence in reader order — rather than silently arbitrary.
snapshots[0].Value.ShouldBe(99.0);
}
// ---- the frozen-peer contract ----
[Fact]
public async Task ReadAsync_whenTheQueryCannotComplete_surfacesBadTimeoutWithinTheDeadline()
{
using var fixture = new SqlitePollFixture();
// A held EXCLUSIVE transaction is this rig's frozen peer: the SELECT below cannot proceed and
// Microsoft.Data.Sqlite's busy-retry loop is fully SYNCHRONOUS — it neither returns nor honours a
// cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async
// API that ignores its deadline), so only a real wall-clock bound can end this call early.
using var locker = fixture.OpenNewConnection();
Execute(locker, "BEGIN EXCLUSIVE");
try
{
// Deliberately inverted against the authoring rule (operationTimeout > commandTimeout): the
// point is to prove the CLIENT-side bound fires while the server-side backstop would still wait.
var reader = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
var clock = Stopwatch.StartNew();
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
clock.Stop();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
snapshots[0].Value.ShouldBeNull();
// Without the wall-clock bound this returns at CommandTimeout (30 s), not at 0.5 s.
clock.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
finally
{
Execute(locker, "ROLLBACK");
}
}
[Fact]
public async Task ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQueryTrulyFinishes()
{
// The reason the concurrency slot is released by the WORK and not by the waiter: a timed-out group
// is still holding a connection, so handing its slot back at the deadline would let the next poll
// open another one — and a database that stays frozen would accumulate one connection per poll pass
// forever. This test is the only thing that distinguishes the two designs; the concurrency-cap test
// above never times a group out, and the BadTimeout test above never counts connections.
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
// commandTimeout deliberately far beyond the test's own horizon: the wedged query must stay
// wedged for as long as the lock is held, so nothing but the test releases the slot.
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
maxConcurrentGroups: 1, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
var locker = fixture.OpenNewConnection();
Execute(locker, "BEGIN EXCLUSIVE"); // outside the try: a ROLLBACK with no open transaction throws
try
{
// Poll 1 wedges against the lock and times out, abandoning a still-running query.
var first = await reader.ReadAsync(["Speed"], CancellationToken.None);
first[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
factory.Created.ShouldBe(1);
factory.Live.ShouldBe(1); // the zombie still owns its connection
// (a) The slot is STILL held. maxConcurrentGroups is 1, so poll 2 cannot even reach the
// factory: it times out waiting on the gate. Created staying at 1 is the load-bearing
// assertion — release-from-the-waiter would make this 2.
var second = await reader.ReadAsync(["Speed"], CancellationToken.None);
second[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
factory.Created.ShouldBe(1);
factory.Live.ShouldBe(1);
}
finally
{
Execute(locker, "ROLLBACK");
locker.Dispose();
}
// (b) With the lock gone the zombie's query completes (or faults on its own cancelled deadline),
// closes its connection, and only then hands the slot back.
(await WaitUntilAsync(() => factory.Live == 0, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("the abandoned group never released its connection");
var third = await reader.ReadAsync(["Speed"], CancellationToken.None);
third[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
factory.Created.ShouldBe(2); // the slot was reusable exactly once the zombie finished
factory.Live.ShouldBe(0);
}
[Fact]
public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation()
{
// Deliberate asymmetry: OUR deadline expiring is a data-quality outcome (BadTimeout snapshots, so
// clients see the staleness); the CALLER cancelling is the engine tearing the poll down, and must
// propagate rather than be laundered into a snapshot nobody will consume.
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
using var cancelled = new CancellationTokenSource();
await cancelled.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(
async () => await reader.ReadAsync(["Speed"], cancelled.Token));
}
[Fact]
public async Task ReadAsync_whenTheDatabaseCannotBeOpened_throwsSoTheEngineBacksOff()
{
// IReadable's contract: per-tag failures are Bad-coded snapshots, but an unreachable driver throws.
using var fixture = new SqlitePollFixture();
var unreachable = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"otopcua-sql-no-such-dir-{Guid.NewGuid():N}", "db.sqlite"),
}.ToString();
var reader = new SqlPollReader(
fixture.Factory, unreachable, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
await Should.ThrowAsync<DbException>(
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
}
// ---- connection lifecycle ----
[Theory]
[InlineData(1)]
[InlineData(3)]
public async Task ReadAsync_neverHoldsMoreConnectionsThanMaxConcurrentGroups(int maxConcurrentGroups)
{
using var fixture = new SqlitePollFixture();
// Three distinct group keys — key-value, wide-row where-pair, wide-row topByTimestamp.
var tags = new[]
{
KvTag("Speed", SqlitePollFixture.PresentKey),
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn),
};
// The delay makes the groups genuinely overlap; without it a SQLite query is too fast for
// concurrency to be observable at all and the cap assertion below would pass vacuously.
var factory = new TrackingFactory(TimeSpan.FromMilliseconds(150));
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: TimeSpan.FromSeconds(30),
maxConcurrentGroups: maxConcurrentGroups, nullIsBad: false, resolve: Resolver(tags));
var snapshots = await reader.ReadAsync(["Speed", "Oven", "Newest"], CancellationToken.None);
snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good);
factory.Created.ShouldBe(3);
factory.Peak.ShouldBe(maxConcurrentGroups); // == 3 for the uncapped run proves the overlap is real
factory.Live.ShouldBe(0); // every connection closed — no leak under the poll loop
}
[Fact]
public async Task ReadAsync_repeatedPolls_leakNoConnections()
{
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
for (var poll = 0; poll < 5; poll++)
{
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
}
factory.Created.ShouldBe(5);
factory.Live.ShouldBe(0);
}
// ---- argument guards ----
[Fact]
public void Constructor_rejectsAnUnusableTimeoutOrConcurrencyCap()
{
var dialect = new SqliteDialect();
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null));
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null));
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null));
Should.Throw<ArgumentException>(() => new SqlPollReader(
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
}
[Fact]
public async Task ReadAsync_rejectsANullReferenceList()
=> await Should.ThrowAsync<ArgumentNullException>(async () =>
{
using var fixture = new SqlitePollFixture();
await Reader(fixture).ReadAsync(null!, CancellationToken.None);
});
// ---- helpers ----
private static SqlPollReader Reader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
=> Reader(fixture, nullIsBad: false, tags: tags);
private static SqlPollReader Reader(
SqlitePollFixture fixture, bool nullIsBad, params SqlTagDefinition[] tags)
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags));
private static SqlPollReader Reader(
SqlitePollFixture fixture, ILogger logger, params SqlTagDefinition[] tags)
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false, resolve: Resolver(tags), logger: logger);
/// <summary>
/// Adds a second <see cref="SqlitePollFixture.WideRowTable"/> row for the station the wide-row tests
/// select, breaking that model's one-row-per-selector contract.
/// </summary>
private static void AddSecondRowForPresentStation(SqlitePollFixture fixture)
=> fixture.Execute(string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO {SqlitePollFixture.WideRowTable}
({SqlitePollFixture.WideRowSelectorColumn}, oven_temp, pressure,
{SqlitePollFixture.TimestampColumn})
VALUES ({SqlitePollFixture.PresentStation}, 999.0, 9.9, '2026-07-24T10:00:09Z')
"""));
/// <summary>Runs one statement on a caller-owned connection.</summary>
private static void Execute(SqliteConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// <summary>
/// Polls <paramref name="condition"/> until it holds or <paramref name="limit"/> elapses. Bounded on
/// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it
/// does not.
/// </summary>
private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan limit)
{
var clock = Stopwatch.StartNew();
while (clock.Elapsed < limit)
{
if (condition()) return true;
await Task.Delay(25);
}
return condition();
}
/// <summary>The driver's RawPath→definition table, standing in for <c>EquipmentTagRefResolver</c>.</summary>
private static Func<string, SqlTagDefinition?> Resolver(params SqlTagDefinition[] tags)
{
var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
return rawPath => table.GetValueOrDefault(rawPath);
}
private static SqlTagDefinition KvTag(
string name,
string keyValue,
DriverDataType? declaredType = null,
string? timestampColumn = SqlitePollFixture.TimestampColumn)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: timestampColumn,
DeclaredType: declaredType);
private static SqlTagDefinition WideTag(
string name,
string columnName,
string? selectorValue = null,
string? topByTimestamp = null,
string? timestampColumn = SqlitePollFixture.TimestampColumn)
=> new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
ColumnName: columnName,
TimestampColumn: timestampColumn,
RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
RowSelectorValue: selectorValue,
RowSelectorTopByTimestamp: topByTimestamp);
/// <summary>
/// Captures the reader's log so "reported, not silent" can be asserted rather than assumed — the
/// contract-violation warnings are the reader's only signal that a source is misauthored, so they are
/// behaviour, not diagnostics.
/// </summary>
private sealed class RecordingLogger : ILogger
{
/// <summary>Every record written, level + rendered message.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => Scope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class Scope : IDisposable
{
public static Scope Instance { get; } = new();
public void Dispose() { }
}
}
/// <summary>
/// A <see cref="DbProviderFactory"/> over SQLite that counts how many connections the reader has
/// created and how many are alive at once — the only way to observe the connection lifecycle from
/// outside, since <see cref="SqlPollReader"/> deliberately owns its connections end to end.
/// <para>The optional <c>createDelay</c> widens the window each connection is alive so that
/// concurrent group execution is actually observable against a database whose queries would
/// otherwise finish in microseconds.</para>
/// </summary>
private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory
{
private readonly object _sync = new();
private int _live;
private int _peak;
private int _created;
/// <summary>How many connections the reader has asked the factory for.</summary>
public int Created { get { lock (_sync) { return _created; } } }
/// <summary>How many created connections have not yet closed.</summary>
public int Live { get { lock (_sync) { return _live; } } }
/// <summary>The high-water mark of <see cref="Live"/>.</summary>
public int Peak { get { lock (_sync) { return _peak; } } }
public override DbConnection CreateConnection()
{
var connection = SqliteFactory.Instance.CreateConnection()!;
connection.StateChange += OnStateChange;
lock (_sync)
{
_created++;
_live++;
if (_live > _peak) _peak = _live;
}
if (createDelay > TimeSpan.Zero) Thread.Sleep(createDelay);
return connection;
}
private void OnStateChange(object sender, StateChangeEventArgs e)
{
if (e.CurrentState != ConnectionState.Closed && e.CurrentState != ConnectionState.Broken) return;
lock (_sync) { _live--; }
}
}
}