feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery

SqlDriver implements IDriver / ITagDiscovery / IReadable / ISubscribable /
IHostConnectivityProbe / IAsyncDisposable over the landed SqlPollReader,
mirroring ModbusDriver. IWritable is deliberately absent: v1 is read-only
structurally, and every discovered variable is SecurityClass=ViewOnly.

The shell classifies poll outcomes because nothing below it does. The reader
honours IReadable literally — it throws only when the database is unreachable
and Bad-codes everything else — so a frozen database returns all-BadTimeout
snapshots through a perfectly successful PollGroupEngine tick. Without
ObservePollOutcome the driver would report Healthy while every value was Bad.
Connection-class codes (BadTimeout / BadCommunicationError) degrade health and
report the host Stopped; authoring-class codes (unresolvable RawPath, absent
row, type mismatch) change nothing, so a tag typo never reports the database
down. It deliberately does NOT synthesise an exception to earn engine backoff:
the engine's exception path publishes nothing, which would cost clients the
Bad quality the reader went out of its way to produce.

Initialize builds the authored RawPath table first (pure, cannot fail; a
malformed TagConfig is logged and skipped) then verifies liveness over one
open-use-dispose connection bounded by wall clock as well as by token (the
R2-01 lesson) — failure records Faulted and rethrows so DriverInstanceActor
retries. The connection string is never logged: a credential-free
server/database Endpoint is the only rendering that reaches a log, LastError,
or the host status.

DriverTypeNames.Sql is NOT added here — DriverTypeNamesGuardTests asserts
bidirectional parity with registered factories, so the constant must land with
the factory (Task 11). SqlDriver.DriverTypeName carries the string until then.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:48:14 -04:00
parent 0efee7413a
commit 9b30bdeb7a
4 changed files with 1247 additions and 0 deletions
@@ -0,0 +1,490 @@
using System.Data.Common;
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 the <see cref="SqlDriver"/> shell — the part <see cref="SqlPollReader"/> deliberately does not
/// own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery,
/// the poll-engine subscription overlay, and the health + host-connectivity surface.
/// <para><b>The health assertions are the point of this class.</b> The reader returns Bad-coded snapshots
/// rather than throwing for everything short of an unreachable server, so nothing below the shell degrades
/// health on its own: a frozen database yields all-<c>BadTimeout</c> snapshots and a perfectly successful
/// <see cref="PollGroupEngine"/> tick. If the shell does not classify what came back, the driver reports
/// <see cref="DriverState.Healthy"/> while every value is Bad — the failure mode these tests exist to
/// prevent, together with its inverse (a tag typo must NOT report the database down).</para>
/// </summary>
public sealed class SqlDriverTests
{
private const string DriverInstanceId = "sql-1";
// ---- discovery + initialize ----
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.DriverType.ShouldBe("Sql");
driver.DriverInstanceId.ShouldBe(DriverInstanceId);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.ShouldContain(v =>
v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
// v1 is read-only by construction, not by configuration.
driver.ShouldNotBeAssignableTo<IWritable>();
}
[Fact]
public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture,
logger,
KvEntry("Speed", SqlitePollFixture.PresentKey),
new RawTagEntry("Broken", "not json at all", WriteIdempotent: false));
// A single malformed blob must never fail the whole driver.
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Count.ShouldBe(1);
capture.Variables[0].Info.FullName.ShouldBe("Speed");
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken"));
// …and the skipped tag resolves to nothing rather than to someone else's row.
var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
// Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in
// Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not
// seal as a silently-connected driver.
var thrown = await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNullOrWhiteSpace();
// Actionable: it names the endpoint the operator has to go look at…
health.LastError!.ShouldContain(NoSuchDatabaseName);
thrown.Message.ShouldContain(NoSuchDatabaseName);
// …and never the credential-bearing connection string.
health.LastError.ShouldNotContain(SecretToken);
thrown.Message.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
}
[Fact]
public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain()
{
using var fixture = new SqlitePollFixture();
// SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection
// string is unreachable until the directory exists, and reachable the moment it does.
var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}");
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(directory, "late.db"),
}.ToString();
await using var driver = NewDriver(
fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
try
{
Directory.CreateDirectory(directory);
await driver.ReinitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
// The authored table survived the round trip — a recovered driver still serves its tags.
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Count.ShouldBe(1);
}
finally
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
}
}
// ---- IReadable delegation ----
[Fact]
public async Task ReadAsync_delegatesToTheReader_valueForValue()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Temp", SqlitePollFixture.NullValueKey),
KvEntry("Missing", SqlitePollFixture.AbsentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var reference = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: rawPath => rawPath switch
{
"Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey),
"Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey),
"Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey),
_ => null,
});
string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"];
var throughDriver = await driver.ReadAsync(refs, CancellationToken.None);
var throughReader = await reference.ReadAsync(refs, CancellationToken.None);
throughDriver.Count.ShouldBe(throughReader.Count);
for (var i = 0; i < throughDriver.Count; i++)
{
throughDriver[i].Value.ShouldBe(throughReader[i].Value);
throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode);
throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc);
}
}
// ---- the sustained-timeout decision ----
[Fact]
public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, fixture.ConnectionString, CapturingLogger.Null,
// Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while
// the server-side backstop would still be waiting — the reader's frozen-peer shape.
operationTimeout: TimeSpan.FromMilliseconds(500),
commandTimeout: TimeSpan.FromSeconds(30),
rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests).
using var locker = fixture.OpenNewConnection();
using (var begin = locker.CreateCommand())
{
begin.CommandText = "BEGIN EXCLUSIVE";
begin.ExecuteNonQuery();
}
try
{
var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
// The reader does exactly what it promises — Bad-coded snapshots, no exception…
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
// …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health.
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNullOrWhiteSpace();
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
}
finally
{
using var rollback = locker.CreateCommand();
rollback.CommandText = "ROLLBACK";
rollback.ExecuteNonQuery();
}
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand()
{
// The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when
// the Bad codes are connection-class. An authoring typo reporting "the database is down" would send
// an operator to the wrong system entirely.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
}
// ---- ISubscribable (poll-engine overlay) ----
[Fact]
public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var first = new TaskCompletionSource<DataChangeEventArgs>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changes = 0;
driver.OnDataChange += (_, args) =>
{
Interlocked.Increment(ref changes);
first.TrySetResult(args);
};
var handle = await driver.SubscribeAsync(
["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
initial.FullReference.ShouldBe("Speed");
initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
initial.SubscriptionHandle.ShouldBe(handle);
await driver.UnsubscribeAsync(handle, CancellationToken.None);
driver.ActiveSubscriptionCount.ShouldBe(0);
// Unsubscribe awaits the loop task, so nothing may arrive after it returns.
var afterUnsubscribe = Volatile.Read(ref changes);
await Task.Delay(400, TestContext.Current.CancellationToken);
Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
}
// ---- disposal ----
[Fact]
public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent()
{
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var changes = 0;
driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes);
_ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(300, TestContext.Current.CancellationToken);
await driver.DisposeAsync();
driver.ActiveSubscriptionCount.ShouldBe(0);
var afterDispose = Volatile.Read(ref changes);
await Task.Delay(400, TestContext.Current.CancellationToken);
Volatile.Read(ref changes).ShouldBe(afterDispose);
// Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw.
await driver.ShutdownAsync(CancellationToken.None);
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
}
// ---- host identity ----
[Fact]
public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials()
{
using var fixture = new SqlitePollFixture();
const string connectionString =
"Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";";
await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null);
var hosts = driver.GetHostStatuses();
hosts.Count.ShouldBe(1);
hosts[0].HostName.ShouldContain("sqlsrv01");
hosts[0].HostName.ShouldContain("MesStaging");
hosts[0].HostName.ShouldNotContain(SecretToken);
hosts[0].HostName.ShouldNotContain("svc_ot");
hosts[0].State.ShouldBe(HostState.Unknown); // nothing has been attempted yet
}
[Fact]
public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
var transitions = new List<HostStatusChangedEventArgs>();
driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } };
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// A second successful poll must not re-raise — the event is for transitions, not for ticks.
await driver.ReadAsync(["Speed"], CancellationToken.None);
lock (transitions)
{
transitions.Count.ShouldBe(1);
transitions[0].OldState.ShouldBe(HostState.Unknown);
transitions[0].NewState.ShouldBe(HostState.Running);
}
}
// ---- helpers ----
/// <summary>A distinctive password token: any assertion that finds it has found a credential leak.</summary>
private const string SecretToken = "hunter2-do-not-log";
/// <summary>The unreachable connection string's database file name, used as the actionable-error probe.</summary>
private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir";
/// <summary>
/// The driver's <c>DriverConfig</c> JSON. The shell serves its typed options (the factory, Task 9,
/// owns parsing) exactly as <c>ModbusDriver</c> does, so this is deliberately inert.
/// </summary>
private const string ConfigJson = """{"provider":"SqlServer"}""";
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, CapturingLogger.Null, rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> NewDriver(fixture, fixture.ConnectionString, logger, rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags)
=> NewDriver(
fixture, connectionString, logger,
operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10),
rawTags: rawTags);
/// <summary>
/// Builds the driver through its production constructor. That constructor <b>is</b> the injection
/// seam — dialect, provider factory and already-resolved connection string are all parameters — so no
/// test-only factory is needed on the product type (Task 9 resolves the same three from config).
/// </summary>
private static SqlDriver NewDriver(
SqlitePollFixture fixture,
string connectionString,
CapturingLogger logger,
TimeSpan operationTimeout,
TimeSpan commandTimeout,
IReadOnlyList<RawTagEntry> rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
},
DriverInstanceId,
new SqliteDialect(),
connectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>A connection string whose database cannot be opened — the directory does not exist.</summary>
private static string Unreachable() => new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"),
Password = SecretToken,
}.ToString();
/// <summary>One authored raw tag: a key-value <c>TagConfig</c> blob over the fixture's EAV table.</summary>
private static RawTagEntry KvEntry(string rawPath, string keyValue)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{SqlitePollFixture.KeyValueTable}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), WriteIdempotent: false);
/// <summary>The same tag as <see cref="KvEntry"/>, already typed — for the reference reader.</summary>
private static SqlTagDefinition KvDefinition(string rawPath, string keyValue)
=> new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The folders created, in order.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>The variables registered, in order.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
/// <summary>Captures the driver's log so "skipped and logged" can be asserted rather than assumed.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
/// <summary>A logger that records nothing — for the tests that do not assert on logging.</summary>
public static CapturingLogger Null { get; } = new();
/// <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 => NullScope.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 NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
}