fix(opcuaserver): chunked, per-item-isolated WriteValues with typed partial-failure outcome
This commit is contained in:
@@ -7,9 +7,10 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Value-pipeline batching (Task 6) — <see cref="OtOpcUaNodeManager.WriteValues"/> applies a whole
|
||||
/// batch under ONE acquisition of the manager-wide SDK <c>Lock</c> instead of one per value (the
|
||||
/// per-value take was the steady-state throughput ceiling on the publish path).
|
||||
/// Value-pipeline batching (Task 6) — <see cref="OtOpcUaNodeManager.WriteValues"/> applies a batch under
|
||||
/// one acquisition of the manager-wide SDK <c>Lock</c> per 512-item chunk instead of one per value (the
|
||||
/// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the
|
||||
/// first poll cycle's force-raise-everything batch from stalling every SDK service thread).
|
||||
/// <para>These tests pin the <b>observable</b> contract rather than the locking: every item lands with
|
||||
/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly
|
||||
/// as <see cref="OtOpcUaNodeManager.WriteValue"/> does, and an empty batch does nothing at all.
|
||||
@@ -35,78 +36,174 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
|
||||
public async Task WriteValues_lands_every_value()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
foreach (var name in new[] { "a", "b", "c" })
|
||||
try
|
||||
{
|
||||
nm.EnsureVariable($"eq-1/{name}", parentFolderNodeId: null, displayName: name, dataType: "Double",
|
||||
writable: false, realm: AddressSpaceRealm.Uns);
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
foreach (var name in new[] { "a", "b", "c" })
|
||||
{
|
||||
nm.EnsureVariable($"eq-1/{name}", parentFolderNodeId: null, displayName: name, dataType: "Double",
|
||||
writable: false, realm: AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
var t1 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 8, 15, 10, 0, 1, DateTimeKind.Utc);
|
||||
var t3 = new DateTime(2026, 8, 15, 10, 0, 2, DateTimeKind.Utc);
|
||||
|
||||
nm.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
new("eq-1/a", 1.5d, OpcUaStatus.Good, t1, AddressSpaceRealm.Uns),
|
||||
new("eq-1/b", null, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Uns),
|
||||
new("eq-1/c", 3.5d, 0x80740000u /* BadTypeMismatch */, t3, AddressSpaceRealm.Uns),
|
||||
});
|
||||
|
||||
var a = nm.TryGetVariable("eq-1/a");
|
||||
a.ShouldNotBeNull();
|
||||
a!.Value.ShouldBe(1.5d);
|
||||
a.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
a.Timestamp.ShouldBe(t1);
|
||||
|
||||
var b = nm.TryGetVariable("eq-1/b");
|
||||
b.ShouldNotBeNull();
|
||||
b!.Value.ShouldBeNull();
|
||||
b.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
||||
b.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad,
|
||||
"the batched path must hand the status sub-code to the SDK verbatim (#519)");
|
||||
b.Timestamp.ShouldBe(t2);
|
||||
|
||||
var c = nm.TryGetVariable("eq-1/c");
|
||||
c.ShouldNotBeNull();
|
||||
c!.Value.ShouldBe(3.5d);
|
||||
c.StatusCode.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
c.Timestamp.ShouldBe(t3);
|
||||
}
|
||||
|
||||
var t1 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 8, 15, 10, 0, 1, DateTimeKind.Utc);
|
||||
var t3 = new DateTime(2026, 8, 15, 10, 0, 2, DateTimeKind.Utc);
|
||||
|
||||
nm.WriteValues(new List<ValueWrite>
|
||||
finally
|
||||
{
|
||||
new("eq-1/a", 1.5d, OpcUaStatus.Good, t1, AddressSpaceRealm.Uns),
|
||||
new("eq-1/b", null, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Uns),
|
||||
new("eq-1/c", 3.5d, 0x80740000u /* BadTypeMismatch */, t3, AddressSpaceRealm.Uns),
|
||||
});
|
||||
|
||||
var a = nm.TryGetVariable("eq-1/a");
|
||||
a.ShouldNotBeNull();
|
||||
a!.Value.ShouldBe(1.5d);
|
||||
a.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
a.Timestamp.ShouldBe(t1);
|
||||
|
||||
var b = nm.TryGetVariable("eq-1/b");
|
||||
b.ShouldNotBeNull();
|
||||
b!.Value.ShouldBeNull();
|
||||
b.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
||||
b.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad,
|
||||
"the batched path must hand the status sub-code to the SDK verbatim (#519)");
|
||||
b.Timestamp.ShouldBe(t2);
|
||||
|
||||
var c = nm.TryGetVariable("eq-1/c");
|
||||
c.ShouldNotBeNull();
|
||||
c!.Value.ShouldBe(3.5d);
|
||||
c.StatusCode.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
c.Timestamp.ShouldBe(t3);
|
||||
|
||||
await host.DisposeAsync();
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A batch item naming a NodeId that was never declared lazily creates the variable, exactly
|
||||
/// as the single-value <see cref="OtOpcUaNodeManager.WriteValue"/> create-on-first-write path does —
|
||||
/// and it does so per realm, so a Raw and a UNS item sharing an <c>s=</c> id create two nodes.</summary>
|
||||
/// and it does so PER REALM. Both items here carry the SAME <c>s=</c> id and differ only by realm, so a
|
||||
/// key that ignored the realm would create one node and have the second item overwrite the first. The
|
||||
/// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned
|
||||
/// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup).</summary>
|
||||
[Fact]
|
||||
public async Task WriteValues_creates_missing_variables()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
nm.TryGetVariable("eq-1/lazy").ShouldBeNull();
|
||||
nm.TryGetVariable("Plant/dev1/lazy", AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
|
||||
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
|
||||
nm.WriteValues(new List<ValueWrite>
|
||||
try
|
||||
{
|
||||
new("eq-1/lazy", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
new("Plant/dev1/lazy", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
|
||||
});
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var uns = nm.TryGetVariable("eq-1/lazy");
|
||||
uns.ShouldNotBeNull();
|
||||
uns!.Value.ShouldBe(42);
|
||||
uns.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
uns.Timestamp.ShouldBe(ts);
|
||||
const string SharedId = "Shared/lazy/id";
|
||||
nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull();
|
||||
nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
|
||||
var raw = nm.TryGetVariable("Plant/dev1/lazy", AddressSpaceRealm.Raw);
|
||||
raw.ShouldNotBeNull();
|
||||
raw!.Value.ShouldBe(7);
|
||||
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
|
||||
nm.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
new(SharedId, 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
new(SharedId, 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
|
||||
});
|
||||
|
||||
await host.DisposeAsync();
|
||||
var uns = nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns);
|
||||
uns.ShouldNotBeNull();
|
||||
uns!.Value.ShouldBe(42);
|
||||
uns.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
uns.Timestamp.ShouldBe(ts);
|
||||
|
||||
var raw = nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw);
|
||||
raw.ShouldNotBeNull();
|
||||
raw!.Value.ShouldBe(7);
|
||||
|
||||
// Two DISTINCT nodes, one per namespace — the realm-blind failure mode collapses these to one.
|
||||
raw.NodeId.NamespaceIndex.ShouldNotBe(uns.NodeId.NamespaceIndex);
|
||||
raw.NodeId.NamespaceIndex.ShouldBe(nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw));
|
||||
uns.NodeId.NamespaceIndex.ShouldBe(nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A bad item does NOT drop the tail of the batch. Item 2's NodeId is empty; items 1 and 3 must
|
||||
/// still land, and the aggregate surfaces as a <see cref="ValueWriteBatchException"/> naming the offender
|
||||
/// and the failed/succeeded split.
|
||||
/// <para>This is the durability-critical half: the poll engine updates its last-value cache BEFORE
|
||||
/// raising, so an item dropped here is never re-raised until that tag's value changes again — the node
|
||||
/// would serve a stale value indefinitely, with nothing in the log naming it.</para></summary>
|
||||
[Fact]
|
||||
public async Task A_failing_item_neither_drops_the_tail_nor_hides_which_node_failed()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
new("eq-iso/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
new(string.Empty, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
new("eq-iso/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
}));
|
||||
|
||||
ex.FailedCount.ShouldBe(1);
|
||||
ex.SucceededCount.ShouldBe(2);
|
||||
ex.FirstFailedNodeId.ShouldBe(string.Empty);
|
||||
|
||||
// The load-bearing half: BOTH good items applied — including the one AFTER the failure.
|
||||
var first = nm.TryGetVariable("eq-iso/first");
|
||||
first.ShouldNotBeNull();
|
||||
first!.Value.ShouldBe(1);
|
||||
|
||||
var third = nm.TryGetVariable("eq-iso/third");
|
||||
third.ShouldNotBeNull("the item after the failing one must not be dropped");
|
||||
third!.Value.ShouldBe(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A batch larger than <c>MaxWritesPerLockAcquisition</c> (512) spans several lock acquisitions;
|
||||
/// chunking must be invisible in the outcome — every item still lands exactly once, with its own value.
|
||||
/// Chunk boundaries are the natural off-by-one hazard, so the size is deliberately not a multiple of 512.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_batch_spanning_several_chunks_lands_every_item()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 15, 13, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
const int Count = 1300; // > 2 chunks of 512, and not a multiple of it
|
||||
var items = new List<ValueWrite>(Count);
|
||||
for (var i = 0; i < Count; i++)
|
||||
{
|
||||
items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
nm.WriteValues(items);
|
||||
|
||||
nm.VariableCount.ShouldBe(Count);
|
||||
foreach (var probe in new[] { 0, 511, 512, 1023, 1024, Count - 1 })
|
||||
{
|
||||
var v = nm.TryGetVariable($"eq-chunk/{probe}");
|
||||
v.ShouldNotBeNull($"item {probe} (a chunk boundary) must have landed");
|
||||
v!.Value.ShouldBe(probe);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An empty batch neither throws nor touches the address space.</summary>
|
||||
@@ -114,13 +211,18 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
|
||||
public async Task Empty_batch_is_a_no_op()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var before = nm.VariableCount;
|
||||
Should.NotThrow(() => nm.WriteValues([]));
|
||||
nm.VariableCount.ShouldBe(before);
|
||||
|
||||
await host.DisposeAsync();
|
||||
var before = nm.VariableCount;
|
||||
Should.NotThrow(() => nm.WriteValues([]));
|
||||
nm.VariableCount.ShouldBe(before);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
|
||||
Reference in New Issue
Block a user