Merge b640-641: native alarm state survives rebuild; projector pruned; AbCip ConditionId=RawPath (#641)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:26:47 -04:00
23 changed files with 1060 additions and 50 deletions
@@ -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,26 +196,31 @@ 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
// to send "{ref}#active" with no Kind, so the host dropped every ALMD transition as unrouted (and an
// Unspecified kind would have projected no state change anyway).
if (!wasFaulted && nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
sub.Handle, nodeId, ConditionId: nodeId,
AlarmType: "ALMD",
Message: $"ALMD {nodeId} raised",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
}
else if (wasFaulted && !nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
sub.Handle, nodeId, ConditionId: nodeId,
AlarmType: "ALMD",
Message: $"ALMD {nodeId} cleared",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Clear));
}
}
}
@@ -297,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>
@@ -50,18 +50,13 @@ public sealed partial class MTConnectDriver : IAlarmSource
/// <summary>
/// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
/// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
/// nodes outlive both, and re-raising there would un-acknowledge. Cleared WHOLE when the subscribed set
/// changes: only an alarm-bearing raw-tag delta changes it, that delta is a full address-space rebuild,
/// and a rebuild recreates every condition node inactive — so everything must be told again.
/// nodes outlive both, and re-raising there would un-acknowledge. When the subscribed set changes (an
/// alarm-bearing raw-tag delta, i.e. a full rebuild) only the entries that LEFT the set are dropped: the
/// server carries every surviving condition's state across the rebuild (Gitea #641), so a survivor needs
/// no re-tell, while a condition that comes back later was materialised fresh and is told again.
/// </summary>
private readonly Dictionary<string, MTConnectToldCondition> _deliveredConditions = new(StringComparer.Ordinal);
/// <summary>
/// The reference set of the last <see cref="SubscribeAlarmsAsync"/>, kept across an unsubscribe so a
/// release-then-resubscribe of an identical set is still recognised. Guarded by <see cref="_alarmLock"/>.
/// </summary>
private HashSet<string>? _lastAlarmReferences;
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
private MTConnectConditionTracker _conditions = new();
@@ -108,16 +103,15 @@ public sealed partial class MTConnectDriver : IAlarmSource
lock (_alarmLock)
{
// A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
// condition node — including ones still in the set — was recreated inactive and has been told
// nothing. Re-tell all of them. The SAME set is a reconnect: keep the record, or every still-active
// alarm would be re-raised and lose its acknowledgement.
if (_lastAlarmReferences is null || !_lastAlarmReferences.SetEquals(references))
// Forget only the conditions that LEFT the set (Gitea #641). A changed set is an alarm-bearing delta,
// which the server applies as a full rebuild — but the server carries every surviving condition's
// state (acknowledgement included) across that rebuild, so re-raising a survivor would only
// un-acknowledge it. A condition that left and later comes back was materialised fresh and has no
// record, so it is told again. The SAME set (a reconnect) prunes nothing.
foreach (var conditionId in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList())
{
_deliveredConditions.Clear();
_deliveredConditions.Remove(conditionId);
}
_lastAlarmReferences = references;
}
EmitConditionTransitions(touchedDataItemIds: null, resync: true);