Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
T
Joseph Doherty 8ebc712eff 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
2026-07-16 12:07:11 -04:00

100 lines
6.9 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Wrapper <see cref="IOpcUaAddressSpaceSink"/> that defers to an inner sink swapped in at
/// runtime. Needed because the production sink (<c>SdkAddressSpaceSink</c>) wraps an
/// <c>OtOpcUaNodeManager</c> that only exists after the SDK <c>StandardServer</c> has
/// started — but Akka actors resolve their sink dependency at construction time, before
/// the hosted service has booted the SDK.
///
/// Bound as a singleton in DI on driver-role hosts; the OPC UA hosted service calls
/// <see cref="SetSink"/> once the server is up. Until that swap happens, every call is a
/// no-op against <see cref="NullOpcUaAddressSpaceSink"/>, so the actor stays safe to
/// receive messages from the moment it boots.
/// </summary>
public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance;
/// <summary>Swap in the production sink. Pass <c>null</c> to revert to the null sink
/// (used during graceful shutdown so post-stop writes don't hit a half-disposed manager).</summary>
/// <param name="sink">The sink implementation to use, or null to use the null sink.</param>
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
/// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
// before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the surgical optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
// before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink.
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
// otherwise (before the real SdkAddressSpaceSink is swapped in, or any non-surgical inner) so the caller
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
}