fix(opcuaserver): chunked, per-item-isolated WriteValues with typed partial-failure outcome
This commit is contained in:
@@ -25,8 +25,13 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <summary>Write a batch of Variable values. Semantically identical to calling
|
||||
/// <see cref="WriteValue"/> per item; an SDK-backed sink overrides this to take its internal
|
||||
/// lock ONCE per batch instead of once per value (the hot-path win). The default implementation
|
||||
/// loops <see cref="WriteValue"/> so existing sinks and fakes behave unchanged.</summary>
|
||||
/// loops <see cref="WriteValue"/> so existing sinks and fakes behave unchanged.
|
||||
/// <para>A batching implementation MUST isolate per item — one bad item may not drop the tail of the
|
||||
/// batch — and report the aggregate as a <see cref="ValueWriteBatchException"/>; see that type for why.
|
||||
/// The default loop above predates the contract and is only used by fakes / the no-op sink.</para></summary>
|
||||
/// <param name="values">The value writes to apply, in order.</param>
|
||||
/// <exception cref="ValueWriteBatchException">A batching implementation throws this when some — but not
|
||||
/// necessarily all — items failed; every other item has been applied.</exception>
|
||||
void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -185,6 +190,30 @@ public interface IOpcUaAddressSpaceSink
|
||||
public readonly record struct ValueWrite(
|
||||
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>Aggregate outcome of a partially-failed <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch:
|
||||
/// every non-failing item HAS been applied; this carries what the caller needs to log and meter honestly.</summary>
|
||||
/// <remarks>A batched sink must NOT abandon the tail of a batch when one item throws: the poll engine
|
||||
/// updates its last-value cache BEFORE raising, so a dropped item is never re-raised until that tag's value
|
||||
/// changes again — the node would serve a stale value indefinitely. Hence the sink isolates per item and
|
||||
/// reports the aggregate here rather than letting the first failure escape.</remarks>
|
||||
/// <param name="firstFailedNodeId">The NodeId of the first item that failed (empty when that item's own
|
||||
/// NodeId was null/empty — the validation failure this type most often reports).</param>
|
||||
/// <param name="failedCount">How many items of the batch failed.</param>
|
||||
/// <param name="succeededCount">How many items of the batch were applied.</param>
|
||||
/// <param name="firstFailure">The exception the first failing item produced; the inner exception.</param>
|
||||
public sealed class ValueWriteBatchException(string firstFailedNodeId, int failedCount, int succeededCount, Exception firstFailure)
|
||||
: Exception($"WriteValues batch: {failedCount} of {failedCount + succeededCount} items failed (first: '{firstFailedNodeId}')", firstFailure)
|
||||
{
|
||||
/// <summary>Gets the NodeId of the first item that failed.</summary>
|
||||
public string FirstFailedNodeId { get; } = firstFailedNodeId;
|
||||
|
||||
/// <summary>Gets the number of items of the batch that failed.</summary>
|
||||
public int FailedCount { get; } = failedCount;
|
||||
|
||||
/// <summary>Gets the number of items of the batch that were successfully applied.</summary>
|
||||
public int SucceededCount { get; } = succeededCount;
|
||||
}
|
||||
|
||||
/// <summary>No-op sink. Bound by default so the actors are safe to run in dev / Mac /
|
||||
/// integration tests without a real SDK behind them.</summary>
|
||||
public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
|
||||
@@ -503,8 +503,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node identifier of the variable.</param>
|
||||
/// <param name="value">The new value to write.</param>
|
||||
/// <param name="quality">The OPC UA quality status code.</param>
|
||||
/// <param name="statusCode">The full OPC UA status code, sub-code included (#519 — passed to the SDK verbatim).</param>
|
||||
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
|
||||
/// <param name="realm">Which of the two v3 namespaces the <paramref name="nodeId"/> lives in; the
|
||||
/// namespace index is resolved from this rather than parsed out of the id string.</param>
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(nodeId);
|
||||
@@ -516,28 +518,105 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>Batched <see cref="WriteValue"/>: one <c>Lock</c> acquisition for the whole batch instead of
|
||||
/// one per value — the publish path's per-value lock take was the steady-state throughput ceiling.
|
||||
/// Per-item semantics are identical to <see cref="WriteValue"/> (create-on-first-write, #519 verbatim
|
||||
/// status codes, ClearChangeMasks per node).</summary>
|
||||
/// <summary>The most items <see cref="WriteValues"/> applies under a SINGLE <c>Lock</c> acquisition before
|
||||
/// releasing and re-taking it.
|
||||
/// <para>WHY a cap at all: the lock take is what batching amortises, and the amortisation curve is
|
||||
/// brutally front-loaded — going from one take per value to one per 512 already removes >99.8% of the
|
||||
/// takes, so an unbounded hold buys essentially nothing more. What it DOES buy is a stall: the manager-wide
|
||||
/// <c>Lock</c> is the same lock every SDK service thread (Read / Browse / subscription publish /
|
||||
/// ConditionRefresh) contends for, and the first poll cycle force-raises EVERY tag (the initial-data push),
|
||||
/// so batch #1 is a whole device × its UNS fan-out — thousands of <c>CreateVariable</c> calls, each mutating
|
||||
/// the SDK address space, under one acquisition. 512 is therefore "effectively unbounded for the win,
|
||||
/// bounded for the stall": chunking hands the SDK a scheduling point between chunks.</para></summary>
|
||||
private const int MaxWritesPerLockAcquisition = 512;
|
||||
|
||||
/// <summary>Batched <see cref="WriteValue"/>: one <c>Lock</c> acquisition per chunk of
|
||||
/// <see cref="MaxWritesPerLockAcquisition"/> items instead of one per value — the publish path's per-value
|
||||
/// lock take was the steady-state throughput ceiling. Per-item semantics are identical to
|
||||
/// <see cref="WriteValue"/> (create-on-first-write, #519 verbatim status codes, ClearChangeMasks per node).
|
||||
/// <para><b>Items are isolated.</b> A bad item (empty NodeId, or a throw out of the SDK) does not abandon
|
||||
/// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as
|
||||
/// a <see cref="ValueWriteBatchException"/>. Dropping the tail would be silent and durable — the poll
|
||||
/// engine updates its last-value cache BEFORE raising, so a dropped item is not re-raised until that tag's
|
||||
/// value changes again, leaving the node stale indefinitely.</para></summary>
|
||||
/// <param name="values">The value writes to apply, in order.</param>
|
||||
/// <exception cref="ValueWriteBatchException">When one or more items failed. Every other item HAS been
|
||||
/// applied; the exception carries the first failing NodeId plus the failed/succeeded split so the caller
|
||||
/// can log and meter honestly.</exception>
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Count == 0) return;
|
||||
// Guard BEFORE the empty-batch early-return, so a pre-start call fails the same legible way every
|
||||
// other mutator does regardless of what it happens to carry.
|
||||
EnsureAddressSpaceCreated();
|
||||
if (values.Count == 0) return;
|
||||
|
||||
lock (Lock)
|
||||
// Pre-pass, OUTSIDE the lock: validate each item and resolve its namespace index + map key into a
|
||||
// scratch array. Both jobs belong here — validating outside keeps a malformed item from ever reaching
|
||||
// the SDK, and resolving outside moves the MapKey string allocation (a NodeId + ToString per item)
|
||||
// off the critical section entirely.
|
||||
var resolved = new ResolvedWrite[values.Count];
|
||||
string? firstFailedNodeId = null;
|
||||
Exception? firstFailure = null;
|
||||
var failed = 0;
|
||||
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
var nodeId = values[i].NodeId;
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
{
|
||||
var v = values[i];
|
||||
WriteValueLocked(v.NodeId, NamespaceIndexForRealm(v.Realm), MapKey(v.Realm, v.NodeId),
|
||||
v.Value, v.StatusCode, v.SourceTimestampUtc);
|
||||
failed++;
|
||||
firstFailedNodeId ??= nodeId ?? string.Empty;
|
||||
firstFailure ??= new ArgumentException(
|
||||
$"WriteValues item {i} has a null or empty NodeId.", nameof(values));
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[i] = new ResolvedWrite(
|
||||
nodeId, NamespaceIndexForRealm(values[i].Realm), MapKey(values[i].Realm, nodeId), IsValid: true);
|
||||
}
|
||||
|
||||
var succeeded = 0;
|
||||
for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition)
|
||||
{
|
||||
var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count);
|
||||
lock (Lock)
|
||||
{
|
||||
for (var i = start; i < end; i++)
|
||||
{
|
||||
if (!resolved[i].IsValid) continue;
|
||||
var v = values[i];
|
||||
try
|
||||
{
|
||||
WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key,
|
||||
v.Value, v.StatusCode, v.SourceTimestampUtc);
|
||||
succeeded++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
firstFailedNodeId ??= v.NodeId;
|
||||
firstFailure ??= ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0)
|
||||
{
|
||||
throw new ValueWriteBatchException(
|
||||
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One <see cref="WriteValues"/> item after the outside-the-lock pre-pass: its namespace index and
|
||||
/// map key already resolved, or <paramref name="IsValid"/> false when the item failed validation.</summary>
|
||||
/// <param name="NodeId">The item's bare <c>s=</c> identifier.</param>
|
||||
/// <param name="NamespaceIndex">The namespace index resolved from the item's realm.</param>
|
||||
/// <param name="Key">The realm-qualified map key.</param>
|
||||
/// <param name="IsValid">False when the item failed the pre-pass and must be skipped.</param>
|
||||
private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, string Key, bool IsValid);
|
||||
|
||||
/// <summary>The body of a single value write. <b>Caller MUST hold <c>Lock</c></b> — this is factored out
|
||||
/// solely so <see cref="WriteValues"/> can apply a whole batch under one acquisition; it is byte-identical
|
||||
/// to what <see cref="WriteValue"/> has always done inside its own <c>lock</c>.</summary>
|
||||
@@ -2647,7 +2726,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
|
||||
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
|
||||
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>,
|
||||
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>, <see cref="WriteValues"/>,
|
||||
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
|
||||
/// <see cref="MaterialiseAlarmCondition"/>) and <see cref="ResolveParentFolder"/> assume it has run.
|
||||
/// If one is invoked before the server has started (a sink wired or a publish replayed before
|
||||
|
||||
@@ -339,7 +339,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// <summary>Mirrors <see cref="HandleAttributeUpdate"/> for a whole burst: ONE
|
||||
/// <see cref="IOpcUaAddressSpaceSink.WriteValues"/> call, but the write tally and the
|
||||
/// <c>otopcua.opcua.sink.write</c> meter still advance PER ITEM — batching is a transport change, so
|
||||
/// dashboards must not drop by the batch factor.</summary>
|
||||
/// dashboards must not drop by the batch factor.
|
||||
/// <para>A <see cref="ValueWriteBatchException"/> is a PARTIAL failure: the sink applied every item it
|
||||
/// could and is telling us which one it couldn't. So it is caught ahead of the general catch to (a) name
|
||||
/// the offending node in the log — a bare "the batch threw" cannot be acted on — and (b) still count the
|
||||
/// items that DID land, keeping the per-item counting invariant honest instead of silently under-reporting
|
||||
/// a whole batch because one item was bad.</para></summary>
|
||||
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg)
|
||||
{
|
||||
try
|
||||
@@ -348,6 +353,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
Interlocked.Add(ref _writes, msg.Items.Count);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
catch (ValueWriteBatchException ex)
|
||||
{
|
||||
_log.Warning(ex,
|
||||
"OpcUaPublish: sink.WriteValues partially failed for a {Count}-item batch — {Failed} failed " +
|
||||
"(first: {Node}), {Succeeded} applied",
|
||||
msg.Items.Count, ex.FailedCount, ex.FirstFailedNodeId, ex.SucceededCount);
|
||||
Interlocked.Add(ref _writes, ex.SucceededCount);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(ex.SucceededCount, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteValues threw for a {Count}-item batch", msg.Items.Count);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
/// <summary>
|
||||
/// OpcUaServer-004 — a fresh <see cref="OtOpcUaNodeManager"/> whose <c>CreateAddressSpace</c> has NOT
|
||||
/// yet run (i.e. the server has not started) has a null <c>_root</c>. Every public address-space mutator
|
||||
/// (<see cref="OtOpcUaNodeManager.WriteValue"/>, <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/>,
|
||||
/// (<see cref="OtOpcUaNodeManager.WriteValue"/>, <see cref="OtOpcUaNodeManager.WriteValues"/>,
|
||||
/// <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/>,
|
||||
/// <see cref="OtOpcUaNodeManager.EnsureFolder"/>, <see cref="OtOpcUaNodeManager.EnsureVariable"/>,
|
||||
/// <see cref="OtOpcUaNodeManager.MaterialiseAlarmCondition"/>) must now fail with a legible
|
||||
/// <see cref="InvalidOperationException"/> instead of a bare NRE out of <c>ResolveParentFolder</c> /
|
||||
@@ -78,6 +79,28 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The batched mutator guards the same way — and it does so BEFORE its empty-batch early-return,
|
||||
/// so a pre-start caller gets the legible failure regardless of what the batch happens to carry (an empty
|
||||
/// batch returning quietly would make the guard depend on payload rather than on server state).</summary>
|
||||
[Fact]
|
||||
public async Task WriteValues_before_CreateAddressSpace_throws_InvalidOperationException()
|
||||
{
|
||||
var (host, nm) = await BuildPreStartNodeManagerAsync();
|
||||
try
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(() => nm.WriteValues(new[]
|
||||
{
|
||||
new ValueWrite("eq-1/temp", 1.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns),
|
||||
}));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => nm.WriteValues([]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterialiseAlarmCondition_before_CreateAddressSpace_throws_InvalidOperationException()
|
||||
{
|
||||
|
||||
@@ -36,6 +36,83 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>The sink's <see cref="SdkAddressSpaceSink.WriteValues"/> override forwards the batch to the
|
||||
/// node manager's own batched entry point — every item lands, with its own value and realm.</summary>
|
||||
[Fact]
|
||||
public async Task WriteValues_forwards_the_batch_to_the_node_manager()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
// Through the INTERFACE on purpose: that is the seam production uses, and it is the only call
|
||||
// shape under which deleting the sink's override silently degrades to the per-value default loop
|
||||
// (a concrete-typed call would simply stop compiling, which is not the regression being pinned).
|
||||
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
|
||||
var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
sink.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
new("Plant/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
|
||||
new("eq-1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
});
|
||||
|
||||
nm.VariableCount.ShouldBe(2);
|
||||
|
||||
var raw = nm.TryGetVariable("Plant/dev1/Speed", AddressSpaceRealm.Raw);
|
||||
raw.ShouldNotBeNull();
|
||||
raw!.Value.ShouldBe(42);
|
||||
raw.Timestamp.ShouldBe(ts);
|
||||
|
||||
var uns = nm.TryGetVariable("eq-1/Speed", AddressSpaceRealm.Uns);
|
||||
uns.ShouldNotBeNull();
|
||||
uns!.Value.ShouldBe(42);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pins that the sink actually OVERRIDES <c>WriteValues</c> rather than inheriting the
|
||||
/// interface's per-value default loop. The values-landed assertion above cannot tell the two apart — the
|
||||
/// default loop lands them too — so this uses the one place their behaviour diverges: the manager's
|
||||
/// batched path isolates per item and reports a <see cref="ValueWriteBatchException"/> aggregate, whereas
|
||||
/// the default loop lets the first item's <c>ArgumentException</c> escape and never reaches the tail.
|
||||
/// A test seam (a recording subclass) is not available here — <see cref="SdkAddressSpaceSink"/> is sealed
|
||||
/// and its <c>WriteValues</c> is not virtual — so the forward is pinned by its observable contract
|
||||
/// instead, which is the stronger assertion anyway.</summary>
|
||||
[Fact]
|
||||
public async Task WriteValues_forward_surfaces_the_node_managers_partial_failure_contract()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
|
||||
var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
var ex = Should.Throw<ValueWriteBatchException>(() => sink.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
new("eq-fwd/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
new(string.Empty, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
new("eq-fwd/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
||||
}));
|
||||
|
||||
ex.FailedCount.ShouldBe(1);
|
||||
ex.SucceededCount.ShouldBe(2);
|
||||
|
||||
// The tail item landed — the default per-value loop would have thrown out on item 2 and skipped it.
|
||||
var third = nm.TryGetVariable("eq-fwd/third", AddressSpaceRealm.Uns);
|
||||
third.ShouldNotBeNull();
|
||||
third!.Value.ShouldBe(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
|
||||
/// distinct from value writes.</summary>
|
||||
[Fact]
|
||||
|
||||
@@ -72,6 +72,38 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
|
||||
sink.Threw.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A PARTIAL failure is metered honestly: a sink that applies 2 of 3 items and reports the
|
||||
/// aggregate as a <see cref="ValueWriteBatchException"/> must advance the write tally by the SUCCEEDED
|
||||
/// count, not by zero (the general catch's behaviour) and not by the whole batch. The invariant the
|
||||
/// batching work must not break is that the tally counts per ITEM — so a batch that half-failed reports
|
||||
/// exactly the half that landed.</summary>
|
||||
[Fact]
|
||||
public void Partial_failure_still_counts_the_items_that_landed()
|
||||
{
|
||||
var sink = new PartialFailureSink();
|
||||
// TestActorRef so the actor's own WriteCount tally is readable — the counter the handler advances is
|
||||
// the thing under test, and nothing else observes it.
|
||||
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[]
|
||||
{
|
||||
new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
||||
new ValueWrite("", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
||||
new ValueWrite("Plant/Modbus/dev1/Temp", 3, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
||||
}));
|
||||
|
||||
sink.Threw.ShouldBeTrue();
|
||||
actor.UnderlyingActor.WriteCount.ShouldBe(2,
|
||||
"the two items the sink DID apply must still be counted — not 0 (the general catch) and not 3");
|
||||
|
||||
// The actor survived the partial failure and keeps pumping.
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||
"Plant/Modbus/dev1/Other", 4, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
|
||||
sink.SingleWrites.Count.ShouldBe(1);
|
||||
actor.UnderlyingActor.WriteCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>The pre-existing single-item AttributeValueUpdate path is untouched by the batch addition.</summary>
|
||||
[Fact]
|
||||
public void Single_item_message_still_works()
|
||||
@@ -133,7 +165,8 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
/// <summary>A sink whose batched write throws — drives the handler's catch.</summary>
|
||||
/// <summary>A sink whose batched write throws a PLAIN exception — drives the handler's GENERAL catch (an
|
||||
/// unrecognised sink failure, where nothing is known about what landed, so nothing is counted).</summary>
|
||||
private sealed class ThrowOnWriteValuesSink : RecordingSink
|
||||
{
|
||||
private int _threw;
|
||||
@@ -146,4 +179,33 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
|
||||
throw new InvalidOperationException("simulated batched-write fault");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A sink modelling the real SDK-backed one's PARTIAL failure: it applies every item with a
|
||||
/// non-empty NodeId, then reports the aggregate as a <see cref="ValueWriteBatchException"/>. Drives the
|
||||
/// handler's typed catch.</summary>
|
||||
private sealed class PartialFailureSink : RecordingSink
|
||||
{
|
||||
private int _threw;
|
||||
|
||||
public bool Threw => Volatile.Read(ref _threw) != 0;
|
||||
|
||||
public override void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
{
|
||||
var applied = values.Where(v => !string.IsNullOrEmpty(v.NodeId)).ToArray();
|
||||
var failedCount = values.Count - applied.Length;
|
||||
if (failedCount == 0)
|
||||
{
|
||||
base.WriteValues(values);
|
||||
return;
|
||||
}
|
||||
|
||||
base.WriteValues(applied);
|
||||
Interlocked.Exchange(ref _threw, 1);
|
||||
throw new ValueWriteBatchException(
|
||||
firstFailedNodeId: values.First(v => string.IsNullOrEmpty(v.NodeId)).NodeId,
|
||||
failedCount: failedCount,
|
||||
succeededCount: applied.Length,
|
||||
firstFailure: new ArgumentException("empty NodeId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user