fix(abcip): keep the ALMD last-told state across a re-subscribe so a still-faulted alarm is not re-raised (#641 review)
Now that ALMD transitions route, a re-subscribe (every reconnect, every alarm-set change) started from an empty per-subscription record and re-RAISED every still-faulted alarm, which the host projects as Active+unacked - un-acknowledging a condition the server had just carried across the rebuild with its client ack. The record is now driver-owned and pruned to the new set, mirroring MTConnect's _deliveredConditions. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -70,6 +70,12 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
|
||||
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
|
||||
// double-owned (and thus never double-disposed) by a racing dispose.
|
||||
// Gitea #641 review — forget only the refs that LEFT the set. A survivor keeps its last-seen InFaulted, so
|
||||
// a re-subscribe (every reconnect, every alarm-set change) does not re-RAISE a still-faulted alarm: the
|
||||
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
|
||||
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
|
||||
_driver.AlarmFaultRecord.RetainOnly(sub.SourceNodeIds);
|
||||
|
||||
List<Subscription> stale;
|
||||
lock (_subsLock)
|
||||
{
|
||||
@@ -190,8 +196,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// is intentional and matches the documented behaviour.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
sub.LastInFaulted[nodeId] = nowFaulted;
|
||||
var wasFaulted = _driver.AlarmFaultRecord.Exchange(nodeId, nowFaulted);
|
||||
|
||||
// ConditionId is the subscribed reference itself — the alarm tag's RawPath, the key the host routes
|
||||
// native transitions on — and Kind tells the host's projector what changed (Gitea #641). This used
|
||||
@@ -303,9 +308,44 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
|
||||
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the last <c>InFaulted</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
|
||||
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
|
||||
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
|
||||
/// </summary>
|
||||
internal sealed class AbCipAlarmFaultRecord
|
||||
{
|
||||
private readonly Dictionary<string, bool> _lastInFaulted = new(StringComparer.Ordinal);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>Records <paramref name="nowFaulted"/> for <paramref name="nodeId"/>, returning the prior value
|
||||
/// (<c>false</c> when none).</summary>
|
||||
/// <param name="nodeId">The alarm RawPath.</param>
|
||||
/// <param name="nowFaulted">The state just read.</param>
|
||||
/// <returns>The previously recorded state.</returns>
|
||||
public bool Exchange(string nodeId, bool nowFaulted)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var was = _lastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
_lastInFaulted[nodeId] = nowFaulted;
|
||||
return was;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
|
||||
/// <param name="nodeIds">The newly subscribed refs.</param>
|
||||
public void RetainOnly(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var k in _lastInFaulted.Keys.Where(k => !keep.Contains(k)).ToList()) _lastInFaulted.Remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
|
||||
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
|
||||
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value changes.</summary>
|
||||
|
||||
Reference in New Issue
Block a user