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:
@@ -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
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user