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
@@ -69,6 +69,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc />
public void CompleteAddressSpaceApply() => _inner.CompleteAddressSpaceApply();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
@@ -185,6 +185,19 @@ public interface IOpcUaAddressSpaceSink
/// </summary>
void RebuildAddressSpace();
/// <summary>
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
/// every notifier folder is wired) and DISCARDS any captured state no materialise claimed, so a later
/// deploy that re-adds the same id gets a fresh node rather than a resurrection. <c>OpcUaPublishActor</c>
/// calls it once at the end of every apply, rebuild or not. Idempotent; the default is a no-op.
/// </summary>
void CompleteAddressSpaceApply()
{
}
/// <summary>
/// Announce that nodes were added at runtime (discovered-node injection) under
/// <paramref name="affectedNodeId"/> so subscribed clients refresh their browse
@@ -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);
@@ -65,6 +65,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>Gitea #641: every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
/// <see cref="CompleteAddressSpaceApply"/> runs is discarded. Guarded by <c>Lock</c>.</summary>
private readonly Dictionary<RealmKey, NativeConditionCarryOver> _rebuildCarryOver = new();
/// <summary>Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
/// event at <see cref="CompleteAddressSpaceApply"/> (deferred until then so every notifier folder the
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
/// not reported. Guarded by <c>Lock</c>.</summary>
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
@@ -1432,9 +1442,109 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
// for a later re-add to resurrect.
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
RestoreCarryOver(alarm, conditionKey, carried);
}
}
/// <summary>
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
/// <see cref="RebuildAddressSpace"/>.
/// </summary>
/// <param name="State">The gate-relevant fields, read off the node as <see cref="ReadConditionDelta"/> does.</param>
/// <param name="Time">The condition's <c>Time</c> — the instant its current state came about.</param>
/// <param name="ActiveTransitionTime">When it last changed Active, if the SDK recorded it.</param>
/// <param name="QualityTimestamp">The source timestamp of its Quality annotation, if present.</param>
/// <param name="Comment">The condition's <c>Comment</c> (a client's ack / AddComment text), if any.</param>
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
LocalizedText? Comment = null, string? ClientUserId = null);
/// <summary>
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the NODE, not a host-side cache.</b> The node is the only place that knows the true state:
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
/// the driver or <c>NativeAlarmProjector</c>. Replaying from any host-side record would un-acknowledge it.
/// </para>
/// <para>
/// <b>Why it is needed at all.</b> A rebuild recreates every condition inactive + acknowledged. When the
/// alarm-tag set is unchanged — the common case: adding a <c>UnsTagReference</c> to an alarm-bearing raw
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
/// Without this, every active alarm read normal until its next device transition.
/// </para>
/// </remarks>
/// <param name="condition">The live native condition, about to be discarded.</param>
/// <returns>Its carried state.</returns>
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
ReadConditionDelta(condition),
condition.Time?.Value ?? DateTime.UtcNow,
condition.ActiveState?.TransitionTime?.Value,
condition.Quality?.SourceTimestamp?.Value,
condition.Comment?.Value,
condition.ClientUserId?.Value);
/// <summary>
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
/// under <c>Lock</c>, after the condition is registered.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quality always; the rest only off-normal.</b> Source quality is annotated only on a driver's
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
/// reading <c>BadWaitingForInitialData</c> until the driver next flapped; it is restored silently. A
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
/// materialise built, message and authored severity included, and reports nothing — the same rule the
/// scripted re-assert follows (#487).
/// </para>
/// <para>
/// <b>Reported later, once.</b> The state is applied now, so a Read or ConditionRefresh in the window
/// already sees it, but the one condition event waits for <see cref="CompleteAddressSpaceApply"/>: the
/// equipment folders the condition reports through are wired AFTER this call
/// (<see cref="WireAlarmNotifiers"/>), and reporting now would miss them. Native conditions refuse
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
/// </para>
/// </remarks>
/// <param name="alarm">The new condition instance.</param>
/// <param name="conditionKey">Its key.</param>
/// <param name="carried">What the discarded instance held.</param>
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
{
var state = carried.State;
if (alarm.Quality is not null)
{
alarm.Quality.Value = state.Quality;
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
alarm.Quality.SourceTimestamp.Value = qualityAt;
}
var retained = state.Active || !state.Acknowledged;
if (!retained) return;
alarm.SetActiveState(SystemContext, state.Active);
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
transitionTime.Value = activeAt;
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
alarm.Message.Value = new LocalizedText(state.Message);
alarm.Retain.Value = true;
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
alarm.Time.Value = carried.Time;
alarm.ReceiveTime.Value = carried.Time;
_restoredConditionReports.Add((conditionKey, alarm));
}
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
@@ -3207,6 +3317,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
// conditions are not captured — their engine re-asserts them after the reload (#487).
_rebuildCarryOver.Clear();
_restoredConditionReports.Clear();
foreach (var (key, alarm) in _alarmConditions)
{
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
}
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
@@ -3251,6 +3371,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
/// <remarks>
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
/// </remarks>
public void CompleteAddressSpaceApply()
{
int reported = 0, discarded;
lock (Lock)
{
foreach (var (key, alarm) in _restoredConditionReports)
{
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
// newer instant of a report that did (a client method call on an SDK thread can land between the
// restore and here) — never roll the condition's Time back.
ReportConditionEvent(alarm, alarm.Time.Value);
reported++;
}
discarded = _rebuildCarryOver.Count;
_restoredConditionReports.Clear();
_rebuildCarryOver.Clear();
}
if (reported > 0 || discarded > 0)
{
_logger.LogInformation(
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
reported, discarded);
}
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{
@@ -84,6 +84,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <inheritdoc />
public void CompleteAddressSpaceApply() => _nodeManager.CompleteAddressSpaceApply();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
@@ -1793,9 +1793,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
// snapshot: the projector never sees a client's acknowledgement (that is applied to the node by the
// SDK) and forgets a condition a deploy removed, so a projected "keep the state" can disagree with
// the node — silently acknowledging a retained, unacked condition or rewriting its message and
// severity. Ungated by role (a Secondary keeps quality
// warm, as for connectivity) and no alerts row.
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
{
@@ -2017,6 +2018,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
// Gitea #641 review — the node is already acknowledged (the SDK applies the client's ack); tell the
// projector too, or the next driver Clear projects (Active=false, Acked=prev=false) and un-acknowledges it.
// Most sources (MTConnect, FOCAS) send no Acknowledge transition back, and the projector now survives a
// deploy rather than resetting to its acked default.
_nativeAlarmProjector.MarkAcknowledged(msg.ConditionNodeId);
if (!_children.TryGetValue(target.DriverInstanceId, out var entry))
{
_log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped",
@@ -3078,7 +3085,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_alarmNodeIdByDriverRef.Clear();
_driverRefByAlarmNodeId.Clear();
_alarmMetaByNodeId.Clear();
_nativeAlarmProjector.Clear();
// NOT _nativeAlarmProjector.Clear() (Gitea #641): it is pruned to the still-deployed conditions after the
// maps are rebuilt below, so a redeploy that keeps an alarm keeps its projected prior state.
// The per-driver secondary indexes are rebuilt WHOLE alongside their primaries below — same pass,
// same clear — so no incremental maintenance exists to get out of step with them.
_valueRefsByDriver.Clear();
@@ -3132,6 +3140,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
// Gitea #641 — forget the projected prior state of conditions this composition no longer carries, and keep
// the rest. Clearing it whole on every apply made the next driver Acknowledge of a still-active alarm
// project Active=false (the default prior), and the next Clear of an unacknowledged one project
// Acked=true — on any deploy, rebuild or not. The node manager carries the NODE's state across a rebuild,
// so the two stay in step. Keyed by the bare condition NodeId, exactly as ForwardNativeAlarm projects.
_nativeAlarmProjector.RetainOnly(_driverRefByAlarmNodeId.Keys.ToHashSet(StringComparer.Ordinal));
// Cache the per-driver payload as THE authority, in the same clear-and-repopulate pass as the routing
// maps above so a rename/removal can never leave a stale entry behind. Keyed over the UNION of the two
// ref maps AND the running children: a child with no authored tags must get an explicit EMPTY set (which
@@ -40,9 +40,36 @@ public sealed class NativeAlarmProjector
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
}
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
/// <summary>
/// Gitea #641 review — record a client acknowledgement the node manager applied to <paramref name="nodeId"/>
/// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the
/// stale unacknowledged prior. An untracked condition already defaults to acknowledged.
/// </summary>
/// <param name="nodeId">The acknowledged condition node id.</param>
public void MarkAcknowledged(string nodeId)
{
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true);
}
/// <summary>Clears all tracked per-node state.</summary>
public void Clear() => _prior.Clear();
/// <summary>
/// Gitea #641 — drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
/// the rest. Called on every apply instead of <see cref="Clear"/>: a condition that is still deployed keeps
/// its state across the deploy (the node manager carries the node's state across a rebuild too), so the
/// next Acknowledge does not project <c>Active = false</c> onto an active alarm and the next Clear does not
/// project <c>Acked = true</c> onto an unacknowledged one. A removed condition is forgotten, so a later
/// re-add starts from the default.
/// </summary>
/// <param name="deployedNodeIds">The condition node ids the new composition still materialises.</param>
public void RetainOnly(IReadOnlySet<string> deployedNodeIds)
{
ArgumentNullException.ThrowIfNull(deployedNodeIds);
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
_prior.Remove(nodeId);
}
private static ushort MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 200,
@@ -437,6 +437,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
private void HandleRebuild(RebuildAddressSpace msg)
{
try
{
HandleRebuildCore(msg);
}
finally
{
// Gitea #641 — close the apply on EVERY exit, early returns and a throwing pass included: the node
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
// pending; never throws out of the handler.
try { _sink.CompleteAddressSpaceApply(); }
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
}
}
}
private void HandleRebuildCore(RebuildAddressSpace msg)
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());