feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw realm); wire the single condition as an SDK event notifier of each referencing equipment's UNS folder so one ReportEvent fans to every root without re-reporting per root (which would break Server-object dedup + Part 9 ack correlation). - New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink, forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink + NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the DeferredSinkForwardingReflectionTests realm + forwarding guards + a hand-written forward test. - OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent EnsureFolderIsEventNotifier per equipment folder; tracked per condition in _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no inverse-notifier entry leaks across redeploys. - AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath. - AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts renders the referencing-equipment list as display metadata. - Un-skipped + rewrote the native-alarm dark tests (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a re-trip) + applier wiring test. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -71,6 +71,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
||||
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
||||
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
||||
/// <summary>v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
|
||||
/// the equipment folders wired as EXTRA event-notifier roots for it (via <see cref="WireAlarmNotifiers"/>).
|
||||
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
|
||||
/// <c>ReportEvent</c> fans one event to every referencing equipment's UNS folder through the SDK notifier
|
||||
/// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
|
||||
/// (matches <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
|
||||
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
|
||||
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
|
||||
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
|
||||
private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
|
||||
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
|
||||
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
||||
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
||||
@@ -340,6 +350,55 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
|
||||
|
||||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
|
||||
/// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
|
||||
/// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
|
||||
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
|
||||
/// across redeploys.</summary>
|
||||
/// <param name="alarmNodeId">The alarm condition node identifier.</param>
|
||||
/// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
|
||||
/// <returns>The number of notifier entries on the condition.</returns>
|
||||
internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
|
||||
var list = new List<NodeState.Notifier>();
|
||||
alarm.GetNotifiers(SystemContext, list);
|
||||
return list.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
|
||||
/// materialised folder <paramref name="folderNodeId"/> — for an equipment folder wired as an alarm notifier
|
||||
/// this counts the inverse links back to its condition(s). Zero when the folder is absent.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
|
||||
/// <returns>The number of notifier entries on the folder.</returns>
|
||||
internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
|
||||
var list = new List<NodeState.Notifier>();
|
||||
folder.GetNotifiers(SystemContext, list);
|
||||
return list.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
|
||||
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
|
||||
/// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="realm">The realm the folder lives in.</param>
|
||||
/// <returns>True when the folder is a registered root notifier.</returns>
|
||||
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
lock (Lock)
|
||||
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
|
||||
&& _notifierFolders.ContainsKey(folder.NodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||||
/// variable node on first call; subsequent calls update Value + StatusCode +
|
||||
@@ -825,6 +884,91 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <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"/>
|
||||
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
|
||||
/// the instance changes).</summary>
|
||||
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
|
||||
|
||||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||
ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
|
||||
if (notifierFolderNodeIds.Count == 0) return;
|
||||
EnsureAddressSpaceCreated();
|
||||
|
||||
var alarmKey = MapKey(alarmRealm, alarmNodeId);
|
||||
lock (Lock)
|
||||
{
|
||||
// The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
|
||||
// apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
|
||||
// not fault on the notifier wiring.
|
||||
if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
|
||||
{
|
||||
// This CustomNodeManager2 carries no ILogger; log through the SDK's static trace (see the
|
||||
// ReportEvent catch below for the same pattern). Utils.LogInfo is [Obsolete] in 1.5.378 in
|
||||
// favour of an ITelemetryContext this manager doesn't wire — suppress the deprecation.
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} not materialised — skipping notifier wiring", alarmKey);
|
||||
#pragma warning restore CS0618
|
||||
return;
|
||||
}
|
||||
|
||||
// Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
|
||||
// the entry (the old folder list referenced the discarded instance) when the instance differs.
|
||||
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
|
||||
{
|
||||
wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
|
||||
_alarmNotifierWiring[alarmKey] = wiring;
|
||||
}
|
||||
|
||||
foreach (var folderNodeId in notifierFolderNodeIds)
|
||||
{
|
||||
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
|
||||
{
|
||||
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} not present — skipped",
|
||||
folderNodeId, alarmKey);
|
||||
#pragma warning restore CS0618
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
|
||||
// ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
|
||||
// entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
|
||||
// root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
|
||||
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
|
||||
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
|
||||
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
|
||||
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
|
||||
// updates rather than duplicates.
|
||||
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
|
||||
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
|
||||
EnsureFolderIsEventNotifier(folder);
|
||||
|
||||
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
|
||||
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
|
||||
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
|
||||
private void UnwireAlarmNotifiers(string alarmKey)
|
||||
{
|
||||
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
|
||||
foreach (var folder in wiring.Folders)
|
||||
{
|
||||
// bidirectional:true also removes the inverse entry the folder holds back to the condition.
|
||||
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
|
||||
}
|
||||
_alarmNotifierWiring.Remove(alarmKey);
|
||||
}
|
||||
|
||||
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
|
||||
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
|
||||
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
|
||||
@@ -1973,6 +2117,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
alarm.Parent?.RemoveChild(alarm);
|
||||
PredefinedNodes?.Remove(alarm.NodeId);
|
||||
}
|
||||
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
|
||||
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
|
||||
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
|
||||
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
|
||||
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
|
||||
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
|
||||
UnwireAlarmNotifiers(alarmKey);
|
||||
|
||||
_alarmConditions.Clear();
|
||||
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
|
||||
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
|
||||
@@ -2043,6 +2195,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
|
||||
// equipment goes).
|
||||
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
|
||||
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
|
||||
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
|
||||
// this they would keep a dangling inverse-notifier entry to the removed condition.
|
||||
UnwireAlarmNotifiers(key);
|
||||
condition.Parent?.RemoveChild(condition);
|
||||
PredefinedNodes?.Remove(condition.NodeId);
|
||||
_nativeAlarmNodeIds.Remove(key);
|
||||
@@ -2095,6 +2251,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_nativeAlarmNodeIds.Remove(id);
|
||||
}
|
||||
|
||||
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
|
||||
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
|
||||
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
|
||||
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
|
||||
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
|
||||
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
|
||||
foreach (var wiring in _alarmNotifierWiring.Values)
|
||||
{
|
||||
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
|
||||
{
|
||||
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
|
||||
wiring.Folders.Remove(folder);
|
||||
}
|
||||
}
|
||||
|
||||
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
|
||||
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
|
||||
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
|
||||
|
||||
Reference in New Issue
Block a user