diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md
index d13bbf16..f8a6f5a4 100644
--- a/docs/drivers/Mqtt.md
+++ b/docs/drivers/Mqtt.md
@@ -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
diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md
index 37803f39..a31617ca 100644
--- a/docs/drivers/Sql.md
+++ b/docs/drivers/Sql.md
@@ -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.)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index d7f89c77..63bce6d1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -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);
}
+ /// Upper bound on how long shutdown waits for the probe loops to observe cancellation.
+ internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths.
+ /// The loop body uses ConfigureAwait(false) throughout, so blocking here cannot deadlock.
+ ///
+ private void JoinProbeLoopsSync()
+ {
+ var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToArray();
+ if (loops.Length == 0) return;
+ try { Task.WaitAll(loops, LoopJoinTimeout); }
+ catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ }
+ }
+
///
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().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
///
public CancellationTokenSource? ProbeCts { get; set; }
+ ///
+ /// Gets or sets the probe loop task, retained so shutdown can join it (STAB-6).
+ ///
+ public Task? ProbeTask { get; set; }
+
///
/// Gets or sets a value indicating whether the probe has been initialized.
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index 9fb93432..07fc913c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -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);
}
+ /// Upper bound on how long shutdown waits for the background loops to observe cancellation.
+ internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
+
///
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();
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; }
/// Gets or sets the cancellation token source for the fixed-tree loop.
public CancellationTokenSource? FixedTreeCts { get; set; }
+ /// The probe loop task, retained so shutdown can join it (STAB-23).
+ public Task? ProbeTask { get; set; }
+ /// The recycle loop task, retained so shutdown can join it (STAB-23).
+ public Task? RecycleTask { get; set; }
+ /// The fixed-tree loop task, retained so shutdown can join it (STAB-23).
+ public Task? FixedTreeTask { get; set; }
/// Gets or sets the fixed-tree cache for this device.
public FocasFixedTreeCache? FixedTreeCache { get; set; }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index 5001c1aa..3da7bf61 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -327,6 +327,20 @@ public sealed class MTConnectDriver
/// The shared /sample pump task. Guarded by .
private Task? _pumpTask;
+ ///
+ /// The the running pump was started against (STAB-20), guarded by
+ /// 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.
+ ///
+ private long _pumpGeneration;
+
+ ///
+ /// Bumped each time installs a NEW (not on a
+ /// with-copy that only advances the cursor or the probe cache). Written under
+ /// .
+ ///
+ private long _sessionGeneration;
+
///
/// The Agent answered /sample with something that cannot be streamed at all, so the
/// pump gave up. Latched — reconnecting reproduces the identical answer forever, and a
@@ -1808,7 +1822,23 @@ public sealed class MTConnectDriver
// revived by a resubscribe. The genuinely-unrepeatable case is the latch below, not this.
if (_pumpTask is { IsCompleted: false })
{
- return;
+ 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)
@@ -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));
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs
index 6d0b1125..5ebd5d96 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttWriteEncoder.cs
@@ -74,9 +74,20 @@ public enum MqttWriteEncodeError
///
public static class MqttWriteEncoder
{
- /// QoS for an outbound write. See the remarks on .
+ ///
+ /// QoS for a Sparkplug NCMD/DCMD command. See the remarks on . Plain-mode
+ /// writes use instead.
+ ///
public const int WriteQos = 0;
+ ///
+ /// QoS for a Plain-mode writeTopic 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.
+ ///
+ public const int PlainWriteQos = 1;
+
///
/// Encodes one write for the tag under the driver's .
///
@@ -84,9 +95,16 @@ public static class MqttWriteEncoder
/// The resolved tag definition.
/// The value to write.
/// The topic + payload to publish, when this returns .
+ ///
+ /// 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 authored ? authored : birth
+ /// precedence the ingest path coerces reads with. encodes as
+ /// , 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.
+ ///
/// The encode outcome.
///
- /// QoS 0, retain . Retain is the one that matters: a retained
+ /// Retain always; QoS 1 for Plain, 0 for Sparkplug commands. 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 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
/// One encoded outbound write: where it goes and what it carries.
/// The concrete topic to publish on.
/// The encoded message body.
-public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload);
+/// The MQTT QoS to publish at — see / .
+public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload, int Qos = MqttWriteEncoder.WriteQos);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
index 306ea3c8..36f5182b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -544,6 +544,11 @@ public sealed class MqttDriver
///
///
///
+ /// Also the write path's type (STAB-25). 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.
+ ///
+ ///
/// The precedence is the ingest path's, restated.SparkplugIngestor.PublishMetric
/// coerces every value with DataTypeAuthored ? authored : birth; 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.
///
///
- /// Good means "published", not "applied". MQTT has no application-level write
+ /// Good means "published", not "applied". 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);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs
index 02c1d696..ba4a2c98 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs
@@ -66,6 +66,14 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
/// How often the role gate is sampled. Bounds the failover republish latency.
private static readonly TimeSpan RoleSampleInterval = TimeSpan.FromSeconds(1);
+ /// Ceiling on the delay between failed claim attempts (STAB-27).
+ internal static readonly TimeSpan MaxClaimBackoff = TimeSpan.FromSeconds(30);
+
+ /// Consecutive failed claim attempts; reset by a successful claim or a stand-down. Reconcile-loop-owned.
+ 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);
}
}
@@ -334,14 +353,42 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
}
catch (Exception ex)
{
- _logger?.LogWarning(
- ex,
- "MQTT driver '{DriverId}': could not reconcile the Sparkplug primary-host claim for '{HostId}'; will retry.",
- _driverId,
- _hostId);
+ 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,
+ "MQTT driver '{DriverId}': could not reconcile the Sparkplug primary-host claim for '{HostId}'; will retry.",
+ _driverId,
+ _hostId);
+ }
}
}
+ /// Capped exponential delay before claim attempt N+1: 2^N seconds, at most .
+ /// Consecutive failed claim attempts so far (at least 1).
+ /// How long to wait before the next attempt.
+ 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)
{
if (Volatile.Read(ref _connection) is not null)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index d968389e..40ba4ec9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -107,6 +107,21 @@ public interface ISqlDialect
/// The command to configure.
void ConfigureCommand(DbCommand command);
+ ///
+ /// 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 keyValue is configuration text — which a strictly-typed provider will not
+ /// compare against a numeric key column (Npgsql sends text, and
+ /// integer = text 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).
+ /// Implementations must tolerate any they are handed, for the same
+ /// substituted-factory reason as .
+ ///
+ /// The parameter, with and value already set.
+ /// The authored value being bound (never ; may be null).
+ void ConfigureParameter(DbParameter parameter, object? value) { }
+
///
/// The most bound parameters this backend accepts in one command — 2098 usable of SQL Server's
/// 2100, 1000 entries in an Oracle IN expression list, 65535 on the Postgres wire protocol.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
index 8457d1c2..f52cf8d4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
@@ -64,6 +64,21 @@ public sealed class PostgresDialect : ISqlDialect
// Intentionally empty: see the summary.
}
+ ///
+ /// STAB-24: a string-valued bind is sent with so the
+ /// server infers its type from the compared column. Bound as the default text, a tag whose
+ /// keyValue is "3" against an integer key column fails with
+ /// operator does not exist: integer = text. Unknown still carries the value as data — it is
+ /// never interpolated — so the injection boundary is unchanged.
+ ///
+ /// The parameter to adjust.
+ /// The authored value.
+ public void ConfigureParameter(DbParameter parameter, object? value)
+ {
+ if (parameter is NpgsqlParameter npgsql && value is string)
+ npgsql.NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown;
+ }
+
///
/// 65535 — the extended-query protocol carries the parameter count as a signed/unsigned 16-bit field
/// in Bind, so that is a protocol ceiling rather than a server setting, and Npgsql raises it
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
index 2169fc2f..0ad565ad 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
@@ -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);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyShutdownJoinTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyShutdownJoinTests.cs
new file mode 100644
index 00000000..7b610135
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyShutdownJoinTests.cs
@@ -0,0 +1,54 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// 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.
+///
+[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
+ }
+ }
+
+ /// Shutdown blocks on a probe loop parked in runtime init, then the loop disposes its probe runtime.
+ [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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasShutdownJoinTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasShutdownJoinTests.cs
new file mode 100644
index 00000000..6e07ae92
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasShutdownJoinTests.cs
@@ -0,0 +1,57 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// 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.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasShutdownJoinTests
+{
+ /// A client whose connect parks until released and deliberately ignores the cancellation token.
+ 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);
+ }
+ }
+
+ /// Shutdown blocks on a probe loop parked in connect, then disposes the client that connect produced.
+ [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);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs
new file mode 100644
index 00000000..738f7a5f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs
@@ -0,0 +1,98 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// STAB-20 / #602 — a Subscribe that lands between StopSampleStreamAsync 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.
+///
+public sealed class MTConnectPumpGenerationTests
+{
+ private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ /// The pump a mid-restart Subscribe started on the retired client is replaced by one on the new client.
+ [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([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))]);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttWriteEncoderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttWriteEncoderTests.cs
index 20984a5b..56784606 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttWriteEncoderTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttWriteEncoderTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
index 0cffd5b5..cfdcdd5a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
@@ -238,6 +238,53 @@ public sealed class SparkplugPrimaryHostTests
await host.DisposeAsync();
}
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+
+ /// The ladder is exponential from 2 s and capped, so a long outage settles at one attempt per cap.
+ [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;
+ }
+
/// A broker that refuses instantly, so a connect attempt is observable but not slow.
private static MqttDriverOptions Unreachable() =>
Options() with { Host = "127.0.0.1", Port = 1, ConnectTimeoutSeconds = 1 };
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql
index 21c1b520..59ce06da 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql
@@ -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 ───────────────────────────────────────────────────
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs
index fcf8f4a4..e9bc7fd3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs
@@ -114,6 +114,63 @@ public sealed class PostgresReadTests
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
}
+ // ---- integer key columns (STAB-24) ----
+
+ ///
+ /// A key-value tag whose key column is integer reads. Authored keyValue is text, and
+ /// Npgsql's default text bind has no integer = text operator — the read failed with a
+ /// provider error before PostgresDialect.ConfigureParameter sent string binds untyped.
+ ///
+ [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);
+ }
+
+ /// A wide-row whereColumn selector over the integer station_id column reads the matching row.
+ [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) ----
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs
index fa8dd2b3..f514b1be 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs
@@ -104,6 +104,16 @@ public sealed class ColonMarkerDialect : ISqlDialect
lock (_commands) _commands.Add(command);
}
+ /// Every value handed to , in call order.
+ public List