fix(v3-batch4-wp2): AddReference sink seam for Organizes UNS→Raw (Wave A review M1)

Close the Wave-A M1 gap: the sink surface had no way to wire the mandated
cross-tree Organizes reference from each UNS reference Variable to its backing
Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated
realm-qualified AddReference method instead.

- IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId,
  targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent;
  a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't
  fault a deploy. Base-interface capability (no surgical sniff, no transitional
  default — WP3 wires it explicitly per UNS reference variable).
- SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink
  forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops.
- OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key
  (variable/folder/condition via ResolveNodeState), guard both-exist, then wire
  the edge bidirectionally (forward Organizes on source, inverse on target) with a
  ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference
  type resolved by ResolveReferenceType (Organizes default). Added internal
  GetNodeReferences test/diagnostic accessor.
- Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard
  auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed.
- New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally +
  idempotent; missing endpoint is a safe no-op.
- All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the
  solution still builds.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 09:52:30 -04:00
parent 5534698d70
commit e95615cef3
15 changed files with 198 additions and 0 deletions
@@ -1736,6 +1736,97 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <inheritdoc cref="IOpcUaAddressSpaceSink.AddReference" />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
string targetNodeId, AddressSpaceRealm targetRealm,
string referenceType = "Organizes")
{
ArgumentException.ThrowIfNullOrEmpty(sourceNodeId);
ArgumentException.ThrowIfNullOrEmpty(targetNodeId);
EnsureAddressSpaceCreated();
var refType = ResolveReferenceType(referenceType);
var sourceKey = MapKey(sourceRealm, sourceNodeId);
var targetKey = MapKey(targetRealm, targetNodeId);
lock (Lock)
{
var source = ResolveNodeState(sourceKey);
var target = ResolveNodeState(targetKey);
// A missing endpoint is a no-op (logged) — the applier may drive the cross-tree link while a
// concurrent rebuild has torn one side down; never throw out of a deploy-time reference wire.
if (source is null || target is null)
{
#pragma warning disable CS0618 // Utils.LogWarning is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogWarning(
"OtOpcUaNodeManager: AddReference({0}) skipped — {1} node missing (source '{2}' present={3}, target '{4}' present={5}).",
refType, source is null ? "source" : "target", sourceKey, source is not null, targetKey, target is not null);
#pragma warning restore CS0618
return;
}
// Idempotent: a re-apply must not duplicate the edge. Organizes is hierarchical, so wire it
// bidirectionally — forward on the source, inverse (OrganizedBy) on the target — so browse
// resolves both ways. Only ClearChangeMasks a side we actually mutated.
var sourceChanged = false;
var targetChanged = false;
if (!source.ReferenceExists(refType, isInverse: false, target.NodeId))
{
source.AddReference(refType, isInverse: false, target.NodeId);
sourceChanged = true;
}
if (!target.ReferenceExists(refType, isInverse: true, source.NodeId))
{
target.AddReference(refType, isInverse: true, source.NodeId);
targetChanged = true;
}
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>Resolve a materialised node (variable, folder, or alarm condition) by its full
/// (namespace-qualified) map key, or null when no node is registered under that key. Used by
/// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
/// <returns>The node state, or null when unknown.</returns>
private NodeState? ResolveNodeState(string key)
{
if (_variables.TryGetValue(key, out var variable)) return variable;
if (_folders.TryGetValue(key, out var folder)) return folder;
if (_alarmConditions.TryGetValue(key, out var condition)) return condition;
return null;
}
/// <summary>Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified
/// id) using the manager's own <c>SystemContext</c>, or an empty list when the node is unknown. Exposed
/// so <c>SdkAddressSpaceSinkTests</c> can assert the cross-tree <c>Organizes</c> edge
/// <see cref="AddReference"/> wires without needing the (protected) SDK system context.</summary>
/// <param name="nodeId">The node's <c>s=</c> id.</param>
/// <param name="realm">The realm the node lives in.</param>
/// <returns>The node's references, or an empty list when the node is not registered.</returns>
internal IReadOnlyList<IReference> GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
var node = ResolveNodeState(MapKey(realm, nodeId));
if (node is null) return Array.Empty<IReference>();
var list = new List<IReference>();
node.GetReferences(SystemContext, list);
return list;
}
}
/// <summary>Map a hierarchical reference-type name to its SDK <see cref="NodeId"/>. Unknown names fall
/// back to <see cref="ReferenceTypeIds.Organizes"/> (the UNS→Raw cross-tree link).</summary>
/// <param name="referenceType">The reference-type name (e.g. "Organizes").</param>
/// <returns>The reference-type NodeId.</returns>
private static NodeId ResolveReferenceType(string referenceType) => referenceType switch
{
"HasComponent" => ReferenceTypeIds.HasComponent,
"HasProperty" => ReferenceTypeIds.HasProperty,
_ => ReferenceTypeIds.Organizes,
};
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that nodes were
/// added under <paramref name="affectedNodeId"/>. MIRRORS <see cref="BuildNodeShapeChangedEvent"/> exactly —
/// the only differences are <c>Verb = NodeAdded</c> (vs <c>DataTypeChanged</c>) and <c>Affected</c> = the
@@ -65,4 +65,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> _nodeManager.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
}