fix(v3-batch4-wp4): alarm fan-out hardening — event-delivery test, resolution-failure meter, teardown guards (Wave C review M1/M2/M3/L1/L3)
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two equipment folders, fires ONE transition, and asserts a Server-object subscriber gets EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping Server + equipment-folder subscribers each get exactly one copy. Captures via the subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents path delivers nothing for these conditions in the SDK client — the working capture is the fast callback). M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented the DisplayName==Name coupling as a binding invariant. Tests: Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed. M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set, bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced equipment receiving the alarm; binding guard comment added on the classifier's ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set. L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry). L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision (defense-in-depth; UNS uniqueness prevents it). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -762,6 +762,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
|
||||
if (_alarmConditions.TryRemove(conditionKey, out var existing))
|
||||
{
|
||||
// Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no
|
||||
// referencing equipment folder keeps a dangling inverse-notifier entry to the dropped
|
||||
// AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no
|
||||
// same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown
|
||||
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
|
||||
UnwireAlarmNotifiers(conditionKey);
|
||||
existing.Parent?.RemoveChild(existing);
|
||||
PredefinedNodes?.Remove(existing.NodeId);
|
||||
}
|
||||
@@ -892,6 +898,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
|
||||
|
||||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
|
||||
/// <remarks>
|
||||
/// Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any
|
||||
/// previously-tracked folder whose id is no longer in <paramref name="notifierFolderNodeIds"/>
|
||||
/// (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always
|
||||
/// passes the COMPLETE referencing-equipment set for the condition, and a de-reference arrives as a
|
||||
/// <c>ChangedRawTags</c> full rebuild (which clears the wiring), so the unwire leg is a no-op on the
|
||||
/// current paths — but it makes this method correct for a FUTURE surgical <c>ChangedRawTags</c> path
|
||||
/// (see <c>AddressSpaceChangeClassifier</c>'s <c>ChangedRawTags → Rebuild</c> branch, which carries the
|
||||
/// matching binding guard).
|
||||
/// </remarks>
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
{
|
||||
@@ -925,6 +941,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_alarmNotifierWiring[alarmKey] = wiring;
|
||||
}
|
||||
|
||||
// The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller
|
||||
// supplied, regardless of whether each is currently materialised. Reconcile against it below.
|
||||
var desiredKeys = notifierFolderNodeIds
|
||||
.Select(id => MapKey(notifierFolderRealm, id))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a
|
||||
// genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the
|
||||
// folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder
|
||||
// (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too.
|
||||
foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList())
|
||||
{
|
||||
wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true);
|
||||
wiring.Folders.Remove(wired);
|
||||
}
|
||||
|
||||
foreach (var folderNodeId in notifierFolderNodeIds)
|
||||
{
|
||||
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
|
||||
|
||||
Reference in New Issue
Block a user