fix(opcua): carry native alarm condition state across a full rebuild (#641)

A full RebuildAddressSpace recreated every native Part 9 condition inactive +
acknowledged, and when the alarm-tag set was unchanged (e.g. one
UnsTagReference added to an alarm-bearing raw tag) nothing re-told the new node
its state: the host does not re-subscribe for an unchanged set, and Galaxy /
OpcUaClient have no snapshot to replay anyway. Active alarms read normal until
their next device transition.

OtOpcUaNodeManager now captures each NATIVE condition's live state (from the
node, so a client-applied ack is kept) before the rebuild clears, restores it
when the same id is re-materialised native in the same apply, and reports ONE
condition event per restored off-normal condition at the new
IOpcUaAddressSpaceSink.CompleteAddressSpaceApply (after notifier wiring).
Unclaimed captures are discarded there, so a later re-add is fresh. Quality is
carried silently for normal conditions too (it is only re-annotated on a
connectivity edge). Scripted conditions are untouched (#487 re-asserts them).
OpcUaPublishActor calls CompleteAddressSpaceApply at the end of every apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:02:12 -04:00
parent a8b07ae12d
commit ddd7678269
9 changed files with 611 additions and 3 deletions
@@ -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, DateTime Time)> _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,102 @@ 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>
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp);
/// <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);
/// <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;
alarm.Time.Value = carried.Time;
alarm.ReceiveTime.Value = carried.Time;
_restoredConditionReports.Add((conditionKey, alarm, carried.Time));
}
/// <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 +3310,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 +3364,38 @@ 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, time) in _restoredConditionReports)
{
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
ReportConditionEvent(alarm, time);
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);
@@ -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());