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
@@ -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);
}
}
@@ -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);
}
}
}
/// <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)
{
if (Volatile.Read(ref _connection) is not null)