perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions
This commit is contained in:
@@ -323,33 +323,41 @@ public class OpcUaDataConnectionTests
|
||||
[Fact]
|
||||
public async Task ReadBatch_ReadsAllTags()
|
||||
{
|
||||
// WP2.1b: ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying
|
||||
// every requested node, not a per-tag loop.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((1.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult<IReadOnlyList<OpcUaReadOutcome>>(
|
||||
ci.Arg<IReadOnlyList<string>>()
|
||||
.Select(n => new OpcUaReadOutcome(n, 1.0, DateTime.UtcNow, 0u, null))
|
||||
.ToList()));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var results = await _adapter.ReadBatchAsync(["tag1", "tag2", "tag3"]);
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(results.Values, r => Assert.True(r.Success));
|
||||
Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "ReadValuesAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL007_ReadBatch_ReturnsPerTagResults_WhenOneTagFails()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-007. ReadBatchAsync looped calling
|
||||
// ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so a
|
||||
// single failing tag aborted the whole batch and the caller got NO results for
|
||||
// the tags that did read successfully — even though ReadResult already carries
|
||||
// a per-tag Success/ErrorMessage shape. After the fix the batch catches per-tag
|
||||
// exceptions and returns a complete map.
|
||||
// Regression test for DataConnectionLayer-007. ReadBatchAsync originally looped
|
||||
// calling ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so
|
||||
// a single failing tag aborted the whole batch and the caller got NO results for
|
||||
// the tags that did read successfully — even though ReadResult already carries a
|
||||
// per-tag Success/ErrorMessage shape. The batch is now one bulk service call, and
|
||||
// the same invariant holds: a per-node failure row never aborts the batch, and
|
||||
// every requested tag comes back in the map.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync("good1", Arg.Any<CancellationToken>())
|
||||
.Returns((1.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValueAsync("bad", Arg.Any<CancellationToken>())
|
||||
.Returns<(object?, DateTime, uint)>(_ => throw new InvalidOperationException("node not found"));
|
||||
_mockClient.ReadValueAsync("good2", Arg.Any<CancellationToken>())
|
||||
.Returns((2.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult<IReadOnlyList<OpcUaReadOutcome>>(
|
||||
ci.Arg<IReadOnlyList<string>>()
|
||||
.Select(n => n == "bad"
|
||||
? new OpcUaReadOutcome(n, null, DateTime.UtcNow, 0x80340000u, "node not found")
|
||||
: new OpcUaReadOutcome(n, 1.0, DateTime.UtcNow, 0u, null))
|
||||
.ToList()));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
@@ -365,28 +373,25 @@ public class OpcUaDataConnectionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenConnectionDropsMidBatch()
|
||||
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenSomeTagsFail()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-017. WriteBatchAsync looped calling
|
||||
// WriteAsync per tag; WriteAsync first calls EnsureConnected(), which throws
|
||||
// InvalidOperationException when the client is disconnected. WriteBatchAsync did
|
||||
// not catch that, so a connection dropping partway through a batch made the whole
|
||||
// WriteBatchAsync throw — the caller lost the per-tag outcomes for the tags that
|
||||
// already wrote. After the fix (mirroring DCL-007's ReadBatchAsync) each per-tag
|
||||
// failure is recorded as a failed WriteResult and the batch returns a complete map.
|
||||
var writeCount = 0;
|
||||
// First write succeeds; then the client "disconnects" so EnsureConnected throws.
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref writeCount) <= 1);
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns((uint)0);
|
||||
|
||||
// Connect leaves IsConnected true for the first WriteAsync's EnsureConnected check.
|
||||
// Regression test for DataConnectionLayer-017. WriteBatchAsync originally looped
|
||||
// calling WriteAsync per tag; a mid-batch fault made the whole call throw and the
|
||||
// caller lost the per-tag outcomes for the tags that already wrote. The batch is
|
||||
// now ONE bulk service call (WP2.1b), and the invariant is unchanged: per-node
|
||||
// failures are reported as failed WriteResult rows and every requested tag is
|
||||
// present in the returned map.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
// Re-arm: IsConnected true for tag1's check, false for tag2 and tag3.
|
||||
var checks = 0;
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref checks) <= 1);
|
||||
_mockClient.WriteValuesAsync(
|
||||
Arg.Any<IReadOnlyList<(string NodeId, object? Value)>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult<IReadOnlyList<OpcUaWriteOutcome>>(
|
||||
ci.Arg<IReadOnlyList<(string NodeId, object? Value)>>()
|
||||
.Select(v => v.NodeId == "tag1"
|
||||
? new OpcUaWriteOutcome(v.NodeId, 0u, null)
|
||||
: new OpcUaWriteOutcome(v.NodeId, 0x80AE0000u, null))
|
||||
.ToList()));
|
||||
|
||||
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?>
|
||||
{
|
||||
@@ -398,11 +403,12 @@ public class OpcUaDataConnectionTests
|
||||
// Every requested tag is present in the result map — the batch was not aborted.
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results["tag1"].Success);
|
||||
// tag2 and tag3 fail at the connection check but are reported per-tag.
|
||||
Assert.False(results["tag2"].Success);
|
||||
Assert.NotNull(results["tag2"].ErrorMessage);
|
||||
Assert.False(results["tag3"].Success);
|
||||
Assert.NotNull(results["tag3"].ErrorMessage);
|
||||
// ONE bulk write, not three single writes.
|
||||
Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "WriteValuesAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -415,8 +421,9 @@ public class OpcUaDataConnectionTests
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns<uint>(_ => throw new OperationCanceledException());
|
||||
_mockClient.WriteValuesAsync(
|
||||
Arg.Any<IReadOnlyList<(string NodeId, object? Value)>>(), Arg.Any<CancellationToken>())
|
||||
.Returns<IReadOnlyList<OpcUaWriteOutcome>>(_ => throw new OperationCanceledException());
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
_adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token));
|
||||
|
||||
Reference in New Issue
Block a user