Merge #602 stability: FOCAS/AbLegacy loop joins on shutdown, Postgres untyped string binds, MTConnect pump generation, MQTT birth-typed writes + QoS1 plain writes + claim backoff

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:33:03 -04:00
21 changed files with 691 additions and 31 deletions
+9 -2
View File
@@ -325,6 +325,9 @@ and this driver ships all three or none of it
2. a matching **retained `OFFLINE` armed as the MQTT Last Will**, so a crash still reports the truth;
3. an explicit `OFFLINE` on a clean stand-down, so edge nodes do not wait out a keep-alive timeout.
A claim the broker keeps refusing backs off (STAB-27): 2 s, 4 s, 8 s … capped at 30 s between attempts, one
Warning per attempt, reset by a successful claim or a stand-down. The role is still sampled every second.
Set `Sparkplug.HostId` and `Sparkplug.ActAsPrimaryHost: true`. A blank host id is **refused** with a
warning — the driver does not publish to `spBv1.0/STATE/`, which is a legal topic reaching nobody.
@@ -460,8 +463,12 @@ every MQTT node materialized read-only regardless of what the operator authored.
| **Plain** | the tag's `writeTopic` | the value as UTF-8 text (`Raw`/`Scalar`) or a bare JSON literal (`Json`) |
| **Sparkplug B** | `spBv1.0/{group}/DCMD/{node}/{device}` for a device metric, `spBv1.0/{group}/NCMD/{node}` for an edge-node metric | a Sparkplug `Payload` carrying the metric **by name**, with an explicit `Datatype` and no `seq` |
Both publish at **QoS 0, `retain: false`**. Retain is the one that matters and is deliberately not
configurable: a retained command would be replayed by the broker to the device on *every* future
Both publish with `retain: false`. A **Plain** write goes at **QoS 1** (STAB-26): the publish completes only
once the broker PUBACKs it, so `Good` means "the broker accepted it", and a broker refusal or a connection
that died under the send is `Bad`. **Sparkplug commands stay at QoS 0** — a redelivered NCMD/DCMD would be
re-applied. A Sparkplug metric the tag did not give a `dataType` is commanded with the type its live birth
certificate declared (STAB-25), the same authored-beats-birth precedence reads use. Retain is the one that
matters and is deliberately not configurable: a retained command would be replayed by the broker to the device on *every* future
subscribe — including the device's own reconnect — so one stale write would be re-applied forever.
### `writeTopic` is separate from `topic`, and its absence is meaningful
+4 -1
View File
@@ -92,7 +92,10 @@ Both value-bearing fields — `whereValue` here and `keyValue` on the `KeyValue`
authored as a JSON string, number or boolean, and are captured as text. They are **bound as
parameters** either way, and matched back as text (the key cell is stringified), so a key column
that holds an INTEGER station id can be authored either `"7"` or `7`. A JSON object, array or `null`
is not a value and is refused. (They were read asymmetrically until 2026-08-10, so a numerically
is not a value and is refused. On **PostgreSQL** a string bind is sent untyped (`NpgsqlDbType.Unknown`,
`PostgresDialect.ConfigureParameter`, STAB-24) so the server infers its type from the compared column;
bound as `text`, a `"7"` against an `integer` key fails with `operator does not exist: integer = text`.
(They were read asymmetrically until 2026-08-10, so a numerically
authored `keyValue` refused the tag while the identically-bound `whereValue` was served — and the
deploy gate reported the key as *missing* when it was right there in the blob.)
@@ -229,7 +229,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
@@ -245,10 +245,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// handles alive. Mirrors the body of ShutdownAsync without awaiting the poll engine
// (nothing has been subscribed yet at init time).
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
JoinProbeLoopsSync();
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
@@ -266,15 +269,45 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Upper bound on how long shutdown waits for the probe loops to observe cancellation.</summary>
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths.
/// The loop body uses <c>ConfigureAwait(false)</c> throughout, so blocking here cannot deadlock.
/// </summary>
private void JoinProbeLoopsSync()
{
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToArray();
if (loops.Length == 0) return;
try { Task.WaitAll(loops, LoopJoinTimeout); }
catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ }
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
// STAB-6: cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
// outlive shutdown and keep a libplctag handle alive past DisposeRuntimes.
foreach (var state in _devices.Values)
try { state.ProbeCts?.Cancel(); } catch { }
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
"AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}",
loops.Count, LoopJoinTimeout, _driverInstanceId);
}
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
@@ -908,10 +941,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// can't poison the rest of the disposal chain.
try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { }
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
JoinProbeLoopsSync();
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
@@ -1037,6 +1073,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// </summary>
public CancellationTokenSource? ProbeCts { get; set; }
/// <summary>
/// Gets or sets the probe loop task, retained so shutdown can join it (STAB-6).
/// </summary>
public Task? ProbeTask { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the probe has been initialized.
/// </summary>
@@ -277,7 +277,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
@@ -287,7 +287,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.RecycleCts = new CancellationTokenSource();
var ct = state.RecycleCts.Token;
_ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct);
}
}
@@ -305,7 +305,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.FixedTreeCts = new CancellationTokenSource();
var ct = state.FixedTreeCts.Token;
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
}
}
@@ -326,6 +326,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Upper bound on how long shutdown waits for the background loops to observe cancellation.</summary>
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
@@ -335,22 +338,43 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
var loops = new List<Task>();
foreach (var state in _devices.Values)
{
// Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
// but we now log the cause so a noisy shutdown leaves a Debug trace.
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
try { state.RecycleCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
state.RecycleCts?.Dispose();
state.RecycleCts = null;
try { state.FixedTreeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
if (state.ProbeTask is { } pt) loops.Add(pt);
if (state.RecycleTask is { } rt) loops.Add(rt);
if (state.FixedTreeTask is { } ft) loops.Add(ft);
}
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning("FOCAS shutdown: {Count} background loop(s) did not stop within {Timeout}; proceeding with teardown",
loops.Count, LoopJoinTimeout);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.RecycleCts?.Dispose();
state.RecycleCts = null;
state.RecycleTask = null;
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.FixedTreeTask = null;
state.DisposeClient();
}
_devices.Clear();
@@ -1575,6 +1599,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? RecycleCts { get; set; }
/// <summary>Gets or sets the cancellation token source for the fixed-tree loop.</summary>
public CancellationTokenSource? FixedTreeCts { get; set; }
/// <summary>The probe loop task, retained so shutdown can join it (STAB-23).</summary>
public Task? ProbeTask { get; set; }
/// <summary>The recycle loop task, retained so shutdown can join it (STAB-23).</summary>
public Task? RecycleTask { get; set; }
/// <summary>The fixed-tree loop task, retained so shutdown can join it (STAB-23).</summary>
public Task? FixedTreeTask { get; set; }
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
public FocasFixedTreeCache? FixedTreeCache { get; set; }
@@ -327,6 +327,20 @@ public sealed class MTConnectDriver
/// <summary>The shared <c>/sample</c> pump task. Guarded by <see cref="_subscriptionLock"/>.</summary>
private Task? _pumpTask;
/// <summary>
/// The <see cref="_sessionGeneration"/> the running pump was started against (STAB-20), guarded by
/// <see cref="_subscriptionLock"/> like the rest of the control block. A pump whose generation is
/// not the current one is bound to a client a lifecycle restart has retired.
/// </summary>
private long _pumpGeneration;
/// <summary>
/// Bumped each time <see cref="StartCoreAsync"/> installs a NEW <see cref="AgentSession"/> (not on a
/// <c>with</c>-copy that only advances the cursor or the probe cache). Written under
/// <see cref="_subscriptionLock"/>.
/// </summary>
private long _sessionGeneration;
/// <summary>
/// The Agent answered <c>/sample</c> with something that cannot be streamed at all, so the
/// pump gave up. Latched — reconnecting reproduces the identical answer forever, and a
@@ -1807,10 +1821,26 @@ public sealed class MTConnectDriver
// stream, and conflating the two means a pump killed by an unexpected fault could never be
// revived by a resubscribe. The genuinely-unrepeatable case is the latch below, not this.
if (_pumpTask is { IsCompleted: false })
{
if (_pumpGeneration == _sessionGeneration)
{
return;
}
// STAB-20: a Subscribe can land between StopSampleStreamAsync clearing the registry slots and
// the teardown that retires the client — StopSampleStreamAsync is still awaiting the OLD pump
// then — and starts a pump on the session about to be disposed. Treating that pump as "the
// running stream" would leave the freshly-installed session with no live stream at all
// (the stale pump just reads its disposed client as a dropped connection). Retire it: it is
// cancelled and can only fail against its disposed client.
try { _pumpCts?.Cancel(); }
catch (ObjectDisposedException) { /* already going away */ }
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} replaced a /sample pump bound to a retired Agent session.",
_driverInstanceId);
}
if (_streamUnsupported)
{
// Refusing IS correct — reconnecting reproduces the identical answer forever — but it
@@ -1856,6 +1886,7 @@ public sealed class MTConnectDriver
var options = Volatile.Read(ref _options);
var cts = new CancellationTokenSource();
_pumpCts = cts;
_pumpGeneration = _sessionGeneration;
_pumpTask = Task.Run(
() => RunSampleStreamAsync(
session.Client, options, session.NextSequence, session.InstanceId, republishOnStart, cts.Token),
@@ -2695,6 +2726,11 @@ public sealed class MTConnectDriver
// One publish, so a concurrent lock-free reader sees the new client, the new probe
// cache, and the pump's opening cursor together or none of them.
lock (_subscriptionLock)
{
_sessionGeneration++;
}
Volatile.Write(
ref _session,
new AgentSession(client, probe, current.InstanceId, current.NextSequence));
@@ -74,9 +74,20 @@ public enum MqttWriteEncodeError
/// </remarks>
public static class MqttWriteEncoder
{
/// <summary>QoS for an outbound write. See the remarks on <see cref="TryEncode"/>.</summary>
/// <summary>
/// QoS for a Sparkplug NCMD/DCMD command. See the remarks on <see cref="TryEncode"/>. Plain-mode
/// writes use <see cref="PlainWriteQos"/> instead.
/// </summary>
public const int WriteQos = 0;
/// <summary>
/// QoS for a Plain-mode <c>writeTopic</c> publish (STAB-26). At QoS 1 the publish completes only
/// once the broker has PUBACKed (or refused) it, so a Good write status means "the broker accepted
/// the message" rather than "the bytes left this socket"; at QoS 0 a broker-side refusal or a
/// connection that dropped under the send was indistinguishable from success.
/// </summary>
public const int PlainWriteQos = 1;
/// <summary>
/// Encodes one write for the tag <paramref name="def"/> under the driver's <paramref name="mode"/>.
/// </summary>
@@ -84,9 +95,16 @@ public static class MqttWriteEncoder
/// <param name="def">The resolved tag definition.</param>
/// <param name="value">The value to write.</param>
/// <param name="encoded">The topic + payload to publish, when this returns <see cref="MqttWriteEncodeError.None"/>.</param>
/// <param name="effectiveDataType">
/// Sparkplug only (STAB-25): the type to encode the metric as when the tag did not author one — the
/// type the metric's live birth certificate declared, the same <c>authored ? authored : birth</c>
/// precedence the ingest path coerces reads with. <see langword="null"/> encodes as
/// <see cref="MqttTagDefinition.DataType"/>, which for an unauthored tag is only the record's
/// default and would command the metric with the wrong wire type. Ignored in Plain mode.
/// </param>
/// <returns>The encode outcome.</returns>
/// <remarks>
/// <b>QoS 0, retain <see langword="false"/>.</b> Retain is the one that matters: a retained
/// <b>Retain <see langword="false"/> always; QoS 1 for Plain, 0 for Sparkplug commands.</b> Retain is the one that matters: a retained
/// command would be replayed by the broker to the device on every future subscribe — including
/// the device's own reconnect — so a single stale write would be re-applied forever. This is the
/// same hazard <see cref="RebirthRequester"/> avoids for NCMD, and the reason neither is
@@ -96,13 +114,14 @@ public static class MqttWriteEncoder
MqttMode mode,
MqttTagDefinition def,
object? value,
out MqttEncodedWrite encoded)
out MqttEncodedWrite encoded,
DriverDataType? effectiveDataType = null)
{
ArgumentNullException.ThrowIfNull(def);
encoded = default;
return mode == MqttMode.SparkplugB
? TryEncodeSparkplug(def, value, out encoded)
? TryEncodeSparkplug(def, value, effectiveDataType, out encoded)
: TryEncodePlain(def, value, out encoded);
}
@@ -163,7 +182,7 @@ public static class MqttWriteEncoder
: value.ToString() ?? "";
}
encoded = new MqttEncodedWrite(def.WriteTopic!.Trim(), Encoding.UTF8.GetBytes(text));
encoded = new MqttEncodedWrite(def.WriteTopic!.Trim(), Encoding.UTF8.GetBytes(text), PlainWriteQos);
return MqttWriteEncodeError.None;
}
@@ -175,6 +194,7 @@ public static class MqttWriteEncoder
private static MqttWriteEncodeError TryEncodeSparkplug(
MqttTagDefinition def,
object? value,
DriverDataType? effectiveDataType,
out MqttEncodedWrite encoded)
{
encoded = default;
@@ -206,7 +226,7 @@ public static class MqttWriteEncoder
}
var metric = new Payload.Types.Metric { Name = metricName };
var error = ApplyValue(metric, def.DataType, value);
var error = ApplyValue(metric, def.DataTypeAuthored ? def.DataType : effectiveDataType ?? def.DataType, value);
if (error != MqttWriteEncodeError.None)
{
return error;
@@ -227,7 +247,7 @@ public static class MqttWriteEncoder
edgeNodeId,
isDevice ? def.DeviceId : null);
encoded = new MqttEncodedWrite(topic, payload.ToByteArray());
encoded = new MqttEncodedWrite(topic, payload.ToByteArray(), WriteQos);
return MqttWriteEncodeError.None;
}
@@ -344,4 +364,5 @@ public static class MqttWriteEncoder
/// <summary>One encoded outbound write: where it goes and what it carries.</summary>
/// <param name="Topic">The concrete topic to publish on.</param>
/// <param name="Payload">The encoded message body.</param>
public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload);
/// <param name="Qos">The MQTT QoS to publish at — see <see cref="MqttWriteEncoder.PlainWriteQos"/> / <see cref="MqttWriteEncoder.WriteQos"/>.</param>
public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload, int Qos = MqttWriteEncoder.WriteQos);
@@ -544,6 +544,11 @@ public sealed class MqttDriver
/// </summary>
/// <remarks>
/// <para>
/// <b>Also the write path's type (STAB-25).</b> <see cref="WriteAsync"/> encodes a Sparkplug
/// command with this same answer, so a birth-typed Int32 metric is commanded as Int32 rather than
/// as the record's default.
/// </para>
/// <para>
/// <b>The precedence is the ingest path's, restated.</b> <c>SparkplugIngestor.PublishMetric</c>
/// coerces every value with <c>DataTypeAuthored ? authored : birth</c>; a discovery surface
/// that disagreed with it would advertise a type no value ever arrives as. The two must be
@@ -674,7 +679,10 @@ public sealed class MqttDriver
/// reference reject every other write in the same OPC UA request.
/// </para>
/// <para>
/// <b>Good means "published", not "applied".</b> MQTT has no application-level write
/// <b>Good means "published", not "applied".</b> A Plain-mode write is published at QoS 1
/// (STAB-26), so "published" means the broker PUBACKed it — a broker-side refusal or a drop under
/// the send is Bad, not Good. Sparkplug NCMD/DCMD stay at QoS 0 (a redelivered command would be
/// re-applied). MQTT has no application-level write
/// acknowledgement, so a successful PUBLISH is the strongest truth available at this seam —
/// the same optimistic-Good posture Galaxy's fire-and-forget write takes. Device-level truth
/// arrives afterwards, as the echoed value on the tag's read topic, and corrects the node
@@ -709,7 +717,8 @@ public sealed class MqttDriver
continue;
}
var error = MqttWriteEncoder.TryEncode(_options.Mode, def, w.Value, out var encoded);
var error = MqttWriteEncoder.TryEncode(
_options.Mode, def, w.Value, out var encoded, ResolveDiscoveredDataType(def));
if (error != MqttWriteEncodeError.None)
{
results[i] = new WriteResult(MapEncodeError(error));
@@ -728,7 +737,7 @@ public sealed class MqttDriver
try
{
await connection
.PublishAsync(encoded.Topic, encoded.Payload, MqttWriteEncoder.WriteQos, retain: false, cancellationToken)
.PublishAsync(encoded.Topic, encoded.Payload, encoded.Qos, retain: false, cancellationToken)
.ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
@@ -66,6 +66,14 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
/// <summary>How often the role gate is sampled. Bounds the failover republish latency.</summary>
private static readonly TimeSpan RoleSampleInterval = TimeSpan.FromSeconds(1);
/// <summary>Ceiling on the delay between failed claim attempts (STAB-27).</summary>
internal static readonly TimeSpan MaxClaimBackoff = TimeSpan.FromSeconds(30);
/// <summary>Consecutive failed claim attempts; reset by a successful claim or a stand-down. Reconcile-loop-owned.</summary>
private int _claimFailures;
private DateTimeOffset _nextClaimAttemptUtc;
private readonly MqttDriverOptions _options;
private readonly string _hostId;
private readonly string _driverId;
@@ -321,10 +329,21 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
{
if (_roleSource.ShouldActAsPrimary)
{
// STAB-27: a claim that keeps failing backs off instead of redialling (and logging a
// Warning) every RoleSampleInterval for as long as the broker refuses it. The role is still
// sampled each tick — only the connect attempt waits.
if (_claimFailures > 0 && _timeProvider.GetUtcNow() < _nextClaimAttemptUtc)
{
return;
}
await ClaimAsync(cancellationToken).ConfigureAwait(false);
_claimFailures = 0;
}
else
{
// Standing down is not a failure, and a later re-promotion should claim at once.
_claimFailures = 0;
await ReleaseAsync("this node is not the redundancy Primary", cancellationToken).ConfigureAwait(false);
}
}
@@ -333,6 +352,24 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
throw;
}
catch (Exception ex)
{
if (_roleSource.ShouldActAsPrimary)
{
_claimFailures++;
var delay = ClaimBackoffFor(_claimFailures);
_nextClaimAttemptUtc = _timeProvider.GetUtcNow() + delay;
// One Warning per ATTEMPT, and attempts are now spaced by the backoff, so this is the
// rate limit: 2 s, 4 s, 8 s … capped at MaxClaimBackoff rather than one line a second.
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': could not claim Sparkplug primary host '{HostId}' (attempt {Attempt}); retrying in {RetryIn}.",
_driverId,
_hostId,
_claimFailures,
delay);
}
else
{
_logger?.LogWarning(
ex,
@@ -341,6 +378,16 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
_hostId);
}
}
}
/// <summary>Capped exponential delay before claim attempt N+1: <c>2^N</c> seconds, at most <see cref="MaxClaimBackoff"/>.</summary>
/// <param name="failures">Consecutive failed claim attempts so far (at least 1).</param>
/// <returns>How long to wait before the next attempt.</returns>
internal static TimeSpan ClaimBackoffFor(int failures)
{
var seconds = Math.Pow(2, Math.Min(failures, 16));
return TimeSpan.FromSeconds(Math.Min(seconds, MaxClaimBackoff.TotalSeconds));
}
private async Task ClaimAsync(CancellationToken cancellationToken)
{
@@ -107,6 +107,21 @@ public interface ISqlDialect
/// <param name="command">The command to configure.</param>
void ConfigureCommand(DbCommand command);
/// <summary>
/// Gives the dialect a chance to adjust a poll-path parameter after its name and value are set and
/// before it is added to the command (STAB-24). Authored key / row-selector values are CLR strings,
/// because a tag's <c>keyValue</c> is configuration text — which a strictly-typed provider will not
/// compare against a numeric key column (Npgsql sends <c>text</c>, and
/// <c>integer = text</c> has no operator). A dialect that needs the server to infer the type from
/// context overrides this. The default does nothing, which is correct for providers that coerce
/// implicitly (SQL Server, ODP.NET).
/// <para>Implementations must tolerate any <see cref="DbParameter"/> they are handed, for the same
/// substituted-factory reason as <see cref="ConfigureCommand"/>.</para>
/// </summary>
/// <param name="parameter">The parameter, with <see cref="DbParameter.ParameterName"/> and value already set.</param>
/// <param name="value">The authored value being bound (never <see cref="DBNull"/>; may be <c>null</c>).</param>
void ConfigureParameter(DbParameter parameter, object? value) { }
/// <summary>
/// The most bound parameters this backend accepts in <b>one</b> command — 2098 usable of SQL Server's
/// 2100, 1000 entries in an Oracle <c>IN</c> expression list, 65535 on the Postgres wire protocol.
@@ -64,6 +64,21 @@ public sealed class PostgresDialect : ISqlDialect
// Intentionally empty: see the summary.
}
/// <summary>
/// STAB-24: a string-valued bind is sent with <see cref="NpgsqlTypes.NpgsqlDbType.Unknown"/> so the
/// server infers its type from the compared column. Bound as the default <c>text</c>, a tag whose
/// <c>keyValue</c> is <c>"3"</c> against an <c>integer</c> key column fails with
/// <c>operator does not exist: integer = text</c>. Unknown still carries the value as data — it is
/// never interpolated — so the injection boundary is unchanged.
/// </summary>
/// <param name="parameter">The parameter to adjust.</param>
/// <param name="value">The authored value.</param>
public void ConfigureParameter(DbParameter parameter, object? value)
{
if (parameter is NpgsqlParameter npgsql && value is string)
npgsql.NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown;
}
/// <summary>
/// 65535 — the extended-query protocol carries the parameter count as a signed/unsigned 16-bit field
/// in <c>Bind</c>, so that is a protocol ceiling rather than a server setting, and Npgsql raises it
@@ -393,6 +393,7 @@ public sealed class SqlPollReader
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
parameter.ParameterName = plan.ParameterBindNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
_dialect.ConfigureParameter(parameter, plan.Parameters[i]);
command.Parameters.Add(parameter);
}
@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// STAB-6 / #602 — the probe loop was fire-and-forget, so shutdown cancelled and disposed while the
/// loop could still be parked in a probe-runtime call that ignores cancellation, leaving its
/// libplctag handle alive past teardown. Shutdown now joins the loop (bounded) first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyShutdownJoinTests
{
private sealed class ParkedInitTag(
AbLegacyTagCreateParams p, TaskCompletionSource entered, TaskCompletionSource release) : FakeAbLegacyTag(p)
{
public override async Task InitializeAsync(CancellationToken ct)
{
entered.TrySetResult();
await release.Task.ConfigureAwait(false); // deliberately ignores ct
}
}
/// <summary>Shutdown blocks on a probe loop parked in runtime init, then the loop disposes its probe runtime.</summary>
[Fact]
public async Task Shutdown_WaitsForProbeLoopParkedInInit_ThenProbeRuntimeIsDisposed()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new ParkedInitTag(p, entered, release),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0" },
}, "drv-join", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(300);
shutdown.IsCompleted.ShouldBeFalse("shutdown must join the probe loop parked in runtime init");
// Positive control: releasing the parked call lets shutdown finish, with the probe runtime released.
release.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(5));
factory.Tags["S:0"].Disposed.ShouldBeTrue();
}
}
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// STAB-23 / #602 — the probe / recycle / fixed-tree loops were fire-and-forget, so shutdown
/// cancelled and disposed while a loop could still be parked inside a connect that ignores
/// cancellation, and that connect then completed against a shut-down driver. Shutdown now joins
/// the loops (bounded) before disposing the client.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasShutdownJoinTests
{
/// <summary>A client whose connect parks until released and deliberately ignores the cancellation token.</summary>
private sealed class ParkedConnectClient(TaskCompletionSource entered, TaskCompletionSource release) : FakeFocasClient
{
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
entered.TrySetResult();
await release.Task.ConfigureAwait(false);
await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
}
}
/// <summary>Shutdown blocks on a probe loop parked in connect, then disposes the client that connect produced.</summary>
[Fact]
public async Task Shutdown_WaitsForLoopParkedInConnect_ThenDisposesItsClient()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release) };
var options = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(10) },
};
var drv = new FocasDriver(options, "focas-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(300);
shutdown.IsCompleted.ShouldBeFalse("shutdown must join the probe loop parked in connect");
// Positive control: releasing the parked connect lets shutdown finish.
release.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(5));
factory.Clients.ShouldNotBeEmpty();
factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
factory.Clients.ShouldAllBe(c => !c.IsConnected);
}
}
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// STAB-20 / #602 — a Subscribe that lands between <c>StopSampleStreamAsync</c> clearing the pump slots
/// and the teardown that retires the client started a pump on the soon-to-be-disposed session. The
/// lifecycle restart then saw "a pump is running" and never started one against the new session, leaving
/// the subscription with no live stream. The pump is now stamped with the session generation and a
/// start request supersedes a pump from an older one.
/// </summary>
public sealed class MTConnectPumpGenerationTests
{
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>The pump a mid-restart Subscribe started on the retired client is replaced by one on the new client.</summary>
[Fact]
public async Task Subscribe_during_a_lifecycle_restart_rebinds_the_pump_to_the_new_client()
{
var clientA = CannedAgentClient.FromFixtures();
var clientB = CannedAgentClient.FromFixtures();
var clients = new Queue<CannedAgentClient>([clientA, clientB]);
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = "http://fixture-agent:5000",
RequestTimeoutMs = 5000,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
},
"mt-gen",
_ => clients.Dequeue());
await driver.InitializeAsync("{}", Ct);
var blockOnce = 0;
var blocking = new ManualResetEventSlim(false);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.OnDataChange += (_, e) =>
{
// Once only: the second subscriber's initial-data callback replays the same 1.5 and must not wedge.
if (e.Snapshot.Value is not 1.5d || Interlocked.Exchange(ref blockOnce, 1) != 0)
{
return;
}
entered.TrySetResult();
blocking.Wait();
};
try
{
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
_ = clientA.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5"));
await entered.Task.WaitAsync(Watchdog, Ct);
// The first pump is now wedged in caller code. A re-initialize clears the pump slots and then
// waits (bounded) on it, which is the window the race lives in.
var restart = driver.InitializeAsync("{}", Ct);
var spins = 0;
while (driver.SampleStreamTask is not null)
{
await Task.Delay(10, Ct);
(++spins).ShouldBeLessThan(500, "restart never cleared the pump slot");
}
// A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client.
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
await clientA.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
restart.IsCompleted.ShouldBeFalse();
blocking.Set();
await restart.WaitAsync(Watchdog, Ct);
// The restart installed a new client; the standing subscription must be streaming from IT.
await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
clientA.IsDisposed.ShouldBeTrue();
}
finally
{
blocking.Set();
await driver.ShutdownAsync(CancellationToken.None);
}
}
}
internal static class MTConnectPumpGenerationChunks
{
public static MTConnectStreamsResult Pos(long first, long next, string value) =>
new(
1655000000L,
next,
first,
[new MTConnectObservation("dev1_pos", value, new DateTime(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc))]);
}
@@ -350,6 +350,55 @@ public sealed class MqttWriteEncoderTests
Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Name.ShouldBe("Node Control/Rebirth");
}
// ---- STAB-25 / STAB-26 (#602) ---------------------------------------------------------------------
[Fact]
public void A_plain_write_publishes_at_QoS1_so_Good_means_the_broker_accepted_it()
{
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("oven/set"), 42.5, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
encoded.Qos.ShouldBe(1);
}
[Fact]
public void A_sparkplug_command_stays_at_QoS0()
{
// Pinned beside the Plain case so the two cannot be "unified" by accident: an NCMD/DCMD retry from
// QoS 1 redelivery would re-apply a command the device already took.
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0, out var encoded)
.ShouldBe(MqttWriteEncodeError.None);
encoded.Qos.ShouldBe(0);
}
[Fact]
public void An_unauthored_sparkplug_type_is_commanded_with_the_birth_declared_type()
{
// DataTypeAuthored=false: the record's Float64 is only a default. The birth said Int32, so the DCMD
// must carry Int32 — a Double-typed command to an Int32 metric is the silent-drop shape.
var def = Sparkplug("Filler1") with { DataTypeAuthored = false };
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, DriverDataType.Int32)
.ShouldBe(MqttWriteEncodeError.None);
var metric = Payload.Parser.ParseFrom(encoded.Payload).Metrics[0];
metric.Datatype.ShouldBe((uint)TahuDataType.Int32);
metric.IntValue.ShouldBe(7u);
}
[Fact]
public void An_authored_sparkplug_type_wins_over_the_birth_type()
{
// Same precedence as the ingest path: authored beats birth.
var def = Sparkplug("Filler1", DriverDataType.Float64) with { DataTypeAuthored = true };
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, DriverDataType.Int32)
.ShouldBe(MqttWriteEncodeError.None);
Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Datatype.ShouldBe((uint)TahuDataType.Double);
}
private static string EncodeTopic(MqttMode mode, MqttTagDefinition def)
{
MqttWriteEncoder.TryEncode(mode, def, 1.0, out var encoded).ShouldBe(MqttWriteEncodeError.None);
@@ -238,6 +238,53 @@ public sealed class SparkplugPrimaryHostTests
await host.DisposeAsync();
}
/// <summary>
/// STAB-27 — a claim the broker keeps refusing used to be redialled (and Warning-logged) on every
/// one-second role sample, forever. It now backs off. The clock is frozen, so the real one-second
/// loop ticks several times without the backoff window ever opening; advancing the clock opens it.
/// </summary>
[Fact]
public async Task A_failing_claim_backs_off_instead_of_redialling_every_tick()
{
var clock = new FrozenClock();
var host = new SparkplugPrimaryHost(
Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: true), timeProvider: clock);
await host.StartAsync(TestContext.Current.CancellationToken);
host.ClaimAttemptsForTests.ShouldBe(1);
// Several real loop ticks elapse while the (frozen) backoff window stays shut.
await Task.Delay(TimeSpan.FromMilliseconds(3200), TestContext.Current.CancellationToken);
host.ClaimAttemptsForTests.ShouldBe(1, "no further claim while inside the backoff window");
// Positive control: once the window passes, the very next tick does try again.
clock.Advance(TimeSpan.FromSeconds(5));
(await WaitUntilAsync(() => host.ClaimAttemptsForTests >= 2, TimeSpan.FromSeconds(10)))
.ShouldBeTrue("the claim must resume after the backoff window");
await host.DisposeAsync();
}
/// <summary>The ladder is exponential from 2 s and capped, so a long outage settles at one attempt per cap.</summary>
[Theory]
[InlineData(1, 2)]
[InlineData(2, 4)]
[InlineData(3, 8)]
[InlineData(4, 16)]
[InlineData(5, 30)]
[InlineData(40, 30)]
public void Claim_backoff_is_exponential_and_capped(int failures, int expectedSeconds)
=> SparkplugPrimaryHost.ClaimBackoffFor(failures).ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
private sealed class FrozenClock : TimeProvider
{
private DateTimeOffset _now = new(2026, 10, 8, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
/// <summary>A broker that refuses instantly, so a connect attempt is observable but not slow.</summary>
private static MqttDriverOptions Unreachable() =>
Options() with { Host = "127.0.0.1", Port = 1, ConnectTimeoutSeconds = 1 };
@@ -80,6 +80,20 @@ VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00'),
(9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01'),
(8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
-- Integer-keyed EAV source (STAB-24): the key column is `integer`, so a key bound as the default
-- `text` fails with "operator does not exist: integer = text". Pins the untyped-bind fix in
-- PostgresDialect.ConfigureParameter for the key-value IN-list path (the wide-row `whereColumn`
-- path is covered by latest_status.station_id above).
CREATE TABLE sqlpoll.int_tag_values (
tag_id integer NOT NULL,
num_value double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.int_tag_values (tag_id, num_value, sample_ts)
VALUES (101, 77.5, TIMESTAMP '2026-07-24 10:00:00'),
(102, 12.0, TIMESTAMP '2026-07-24 10:00:01');
RESET ROLE;
-- ── grants: USAGE + SELECT and nothing else ───────────────────────────────────────────────────
@@ -114,6 +114,63 @@ public sealed class PostgresReadTests
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
}
// ---- integer key columns (STAB-24) ----
/// <summary>
/// A key-value tag whose key column is <c>integer</c> reads. Authored <c>keyValue</c> is text, and
/// Npgsql's default <c>text</c> bind has no <c>integer = text</c> operator — the read failed with a
/// provider error before <c>PostgresDialect.ConfigureParameter</c> sent string binds untyped.
/// </summary>
[Fact]
public async Task ReadAsync_realPostgres_integerKeyColumnComparesAgainstAuthoredText()
{
SkipUnlessLive();
await using var driver = await StartAsync(Tag("Sql/IntKey/Tag101", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified("int_tag_values"),
["keyColumn"] = "tag_id",
["keyValue"] = "101",
["valueColumn"] = PostgresFixture.ValueColumn,
["timestampColumn"] = PostgresFixture.TimestampColumn,
}));
var snapshot = (await driver.ReadAsync(["Sql/IntKey/Tag101"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(77.5);
}
/// <summary>A wide-row <c>whereColumn</c> selector over the <c>integer station_id</c> column reads the matching row.</summary>
[Fact]
public async Task ReadAsync_realPostgres_integerWhereColumnSelectsTheMatchingStation()
{
SkipUnlessLive();
await using var driver = await StartAsync(Tag("Sql/Station7/OvenTemp", new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
["columnName"] = "oven_temp",
["timestampColumn"] = PostgresFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["whereColumn"] = "station_id",
["whereValue"] = "7",
},
}));
var snapshot = (await driver.ReadAsync(["Sql/Station7/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(180.5);
}
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
/// <summary>
@@ -104,6 +104,16 @@ public sealed class ColonMarkerDialect : ISqlDialect
lock (_commands) _commands.Add(command);
}
/// <summary>Every value handed to <see cref="ConfigureParameter"/>, in call order.</summary>
public List<object?> ConfiguredParameterValues { get; } = [];
/// <inheritdoc/>
public void ConfigureParameter(DbParameter parameter, object? value)
{
ArgumentNullException.ThrowIfNull(parameter);
lock (ConfiguredParameterValues) ConfiguredParameterValues.Add(value);
}
/// <inheritdoc/>
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
@@ -24,6 +24,26 @@ public class PostgresDialectTests
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
}
/// <summary>STAB-24: a string bind is untyped so Postgres infers it against an integer key column.</summary>
[Fact]
public void ConfigureParameter_sendsStringBindsAsUnknownType_soIntegerKeysCompare()
{
var p = new Npgsql.NpgsqlParameter("@k0", "3") { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text };
new PostgresDialect().ConfigureParameter(p, "3");
p.NpgsqlDbType.ShouldBe(NpgsqlTypes.NpgsqlDbType.Unknown);
}
/// <summary>Non-string values keep their natural type, and a foreign parameter type is tolerated.</summary>
[Fact]
public void ConfigureParameter_leavesNonStringsAndForeignParametersAlone()
{
var d = new PostgresDialect();
var n = new Npgsql.NpgsqlParameter("@k0", 3) { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Integer };
d.ConfigureParameter(n, 3);
n.NpgsqlDbType.ShouldBe(NpgsqlTypes.NpgsqlDbType.Integer);
Should.NotThrow(() => d.ConfigureParameter(new SqliteParameter("@k0", "3"), "3"));
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier("x\0y"));
@@ -318,6 +318,35 @@ public sealed class SqlCatalogGateDriverTests
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
}
/// <summary>
/// STAB-24: every poll-path bind goes through <see cref="ISqlDialect.ConfigureParameter"/> with the
/// authored value, so a dialect can type it. Without the call the Postgres untyped-bind fix is inert.
/// </summary>
[Fact]
public async Task The_poll_binds_every_authored_value_through_the_dialects_parameter_hook()
{
using var fixture = new SqlitePollFixture();
var dialect = new ColonMarkerDialect();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
dialect,
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
dialect.ConfiguredParameterValues.ShouldContain(SqlitePollFixture.PresentKey);
}
// ---- helpers ----
/// <summary>