fix(opcua): forward ISurgicalAddressSpaceSink through DeferredAddressSpaceSink (F10b surgical path was inert in prod)
This commit is contained in:
@@ -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() { }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user