fix(opcua): forward ISurgicalAddressSpaceSink through DeferredAddressSpaceSink (F10b surgical path was inert in prod)

This commit is contained in:
Joseph Doherty
2026-06-18 13:57:53 -04:00
parent b472bba384
commit 22b0611fb9
2 changed files with 89 additions and 1 deletions
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// 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
public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance;
@@ -69,4 +69,17 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
/// <summary>Rebuilds the address space through the inner sink.</summary>
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <summary>Forwards an in-place tag-attribute update (F10b) to the inner sink when it supports the
/// surgical capability. Returns false otherwise — before the real <c>SdkAddressSpaceSink</c> is
/// swapped in (inner is still the null sink), or any inner sink that isn't surgical — so the caller
/// (Phase7Applier) 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.</summary>
/// <param name="variableNodeId">The node ID of the variable to update in place.</param>
/// <param name="writable">Whether the node should be read/write.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing + historian binding.</param>
/// <returns>True when the inner sink applied the update; false when it lacks the capability or the node is missing.</returns>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname);
}
@@ -83,6 +83,53 @@ public sealed class DeferredAddressSpaceSinkTests
call.HistorianTagname.ShouldBe("MyTag.PV");
}
/// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a
/// surgical inner sink — otherwise <c>Phase7Applier</c> (which injects THIS wrapper on every
/// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the
/// in-place tag-attribute optimization is inert in production (it silently falls back to rebuild).</summary>
[Fact]
public void UpdateTagAttributes_forwards_to_a_surgical_inner_sink()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new SurgicalRecordingSink { Result = true };
deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV")
.ShouldBeTrue();
var call = inner.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe("v-1");
call.Writable.ShouldBeTrue();
call.Historian.ShouldBe("MyTag.PV");
}
/// <summary>The surgical forward returns the inner's own result (false ⇒ node missing) so the caller
/// falls back to a full rebuild.</summary>
[Fact]
public void UpdateTagAttributes_returns_inner_result_when_node_missing()
{
var deferred = new DeferredAddressSpaceSink();
deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null)
.ShouldBeFalse();
}
/// <summary>When the inner sink does NOT implement <see cref="ISurgicalAddressSpaceSink"/> (e.g. the
/// null sink before the real one is swapped in, or any non-surgical sink) the wrapper returns false
/// so the caller rebuilds.</summary>
[Fact]
public void UpdateTagAttributes_returns_false_when_inner_is_not_surgical()
{
var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null)
.ShouldBeFalse();
deferred.SetSink(new RecordingSink()); // a non-surgical inner
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null)
.ShouldBeFalse();
}
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
/// inner sink only records the node id, so the exact state values don't matter here).</summary>
private static AlarmConditionSnapshot Snapshot(bool active = false) =>
@@ -123,4 +170,32 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
}
private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
/// <summary>Gets or sets the value <see cref="UpdateTagAttributes"/> returns.</summary>
public bool Result { get; set; } = true;
/// <summary>Gets the recorded surgical calls.</summary>
public List<(string NodeId, bool Writable, string? Historian)> SurgicalCalls { get; } = new();
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
{
SurgicalCalls.Add((variableNodeId, writable, historianTagname));
return Result;
}
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
}
}