fix(opcuaclient): review fixes - drop driver-side OperationLimits chunking (SDK batches), diagnostics as OTel counter, safe discovery selection, options validation at load (#273 #275 #276 #277 #278)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:55:53 -04:00
parent 935ba8d1d4
commit 1219b7b13e
13 changed files with 406 additions and 652 deletions
@@ -1,111 +1,131 @@
using System.Diagnostics.Metrics;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>#276 — counters are thread-safe and surface through <c>DriverHealth.Diagnostics</c>.</summary>
/// <summary>
/// #276 — session/publish events are published on the <c>otopcua.driver.opcuaclient.events</c> OTel counter.
/// The listener filters on a UNIQUE <c>driver_instance</c> tag: the Meter is process-static, so a
/// name-only filter would also collect parallel tests' measurements.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDiagnosticsTests
{
[Fact]
public void Fresh_snapshot_is_all_zero_with_the_full_key_set()
private sealed class Capture : IDisposable
{
var snap = new OpcUaClientDiagnostics().Snapshot();
private readonly MeterListener _listener = new();
private readonly object _gate = new();
private readonly Dictionary<string, long> _byEvent = new();
snap.Keys.ShouldBe(
[
"PublishResponseCount", "NotificationCount", "DataChangeCount", "BadStatusValueCount",
"PublishErrorCount", "KeepAliveMissCount", "ReconnectAttemptCount", "ReconnectSuccessCount",
"ReconnectExhaustedCount", "LastReconnectUtcTicks", "ReadCallCount", "ReadFailureCount",
"WriteCallCount", "WriteFailureCount", "ChunkedRequestCount",
], ignoreOrder: true);
snap.Values.ShouldAllBe(v => v == 0);
public Capture(string instance)
{
_listener.InstrumentPublished = (inst, l) =>
{
if (inst.Name == "otopcua.driver.opcuaclient.events") l.EnableMeasurementEvents(inst);
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
string? driver = null, evt = null;
foreach (var t in tags)
{
if (t.Key == "driver_instance") driver = t.Value as string;
else if (t.Key == "event") evt = t.Value as string;
}
if (driver != instance || evt is null) return;
lock (_gate) _byEvent[evt] = _byEvent.GetValueOrDefault(evt) + value;
});
_listener.Start();
}
public long this[string evt] { get { lock (_gate) return _byEvent.GetValueOrDefault(evt); } }
public void Dispose() => _listener.Dispose();
}
[Fact]
public void Each_recorder_moves_exactly_its_own_counter()
public void Each_event_lands_on_its_own_tagged_series()
{
var d = new OpcUaClientDiagnostics();
d.RecordPublishResponse();
d.RecordNotifications(3);
d.RecordDataChange(bad: false);
d.RecordDataChange(bad: true);
d.RecordPublishError();
d.RecordKeepAliveMiss();
d.RecordReconnectAttempt();
d.RecordReconnectSucceeded();
d.RecordReconnectExhausted();
d.RecordRead(failed: true);
d.RecordWrite(failed: false);
d.RecordChunkedRequest();
var id = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(id);
var d = new OpcUaClientDiagnostics(id);
var s = d.Snapshot();
s["PublishResponseCount"].ShouldBe(1);
s["NotificationCount"].ShouldBe(3);
s["DataChangeCount"].ShouldBe(2);
s["BadStatusValueCount"].ShouldBe(1);
s["PublishErrorCount"].ShouldBe(1);
s["KeepAliveMissCount"].ShouldBe(1);
s["ReconnectAttemptCount"].ShouldBe(1);
s["ReconnectSuccessCount"].ShouldBe(1);
s["ReconnectExhaustedCount"].ShouldBe(1);
s["LastReconnectUtcTicks"].ShouldBeGreaterThan(0);
s["ReadCallCount"].ShouldBe(1);
s["ReadFailureCount"].ShouldBe(1);
s["WriteCallCount"].ShouldBe(1);
s["WriteFailureCount"].ShouldBe(0);
s["ChunkedRequestCount"].ShouldBe(1);
d.Record(OpcUaClientDiagnostics.PublishResponse);
d.Record(OpcUaClientDiagnostics.Notification, 3);
d.Record(OpcUaClientDiagnostics.DataChange);
d.Record(OpcUaClientDiagnostics.BadStatusValue);
d.Record(OpcUaClientDiagnostics.PublishError);
d.Record(OpcUaClientDiagnostics.KeepAliveMiss);
d.Record(OpcUaClientDiagnostics.ReconnectAttempt);
d.Record(OpcUaClientDiagnostics.ReconnectSuccess);
d.Record(OpcUaClientDiagnostics.ReconnectExhausted);
cap[OpcUaClientDiagnostics.PublishResponse].ShouldBe(1);
cap[OpcUaClientDiagnostics.Notification].ShouldBe(3);
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
cap[OpcUaClientDiagnostics.BadStatusValue].ShouldBe(1);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(1);
cap[OpcUaClientDiagnostics.ReconnectAttempt].ShouldBe(1);
cap[OpcUaClientDiagnostics.ReconnectSuccess].ShouldBe(1);
cap[OpcUaClientDiagnostics.ReconnectExhausted].ShouldBe(1);
}
[Fact]
public void Concurrent_increments_do_not_lose_updates()
public void Another_instances_events_are_not_attributed_to_this_one()
{
var d = new OpcUaClientDiagnostics();
const int threads = 8, each = 5_000;
var mine = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(mine);
// Positive control: my own event IS seen; the other instance's identical event is not.
new OpcUaClientDiagnostics("someone-else").Record(OpcUaClientDiagnostics.KeepAliveMiss);
new OpcUaClientDiagnostics(mine).Record(OpcUaClientDiagnostics.PublishError);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(0);
}
[Fact]
public void Concurrent_records_do_not_lose_updates()
{
var id = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(id);
var d = new OpcUaClientDiagnostics(id);
const int threads = 8, each = 2_000;
Parallel.For(0, threads, _ =>
{
for (var i = 0; i < each; i++) { d.RecordDataChange(bad: i % 2 == 0); d.RecordNotifications(2); }
for (var i = 0; i < each; i++) d.Record(OpcUaClientDiagnostics.DataChange);
});
var s = d.Snapshot();
s["DataChangeCount"].ShouldBe(threads * each);
s["BadStatusValueCount"].ShouldBe(threads * each / 2);
s["NotificationCount"].ShouldBe(threads * each * 2);
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(threads * each);
}
[Fact]
public void Snapshot_is_a_point_in_time_copy()
public void Zero_or_negative_counts_are_ignored()
{
var d = new OpcUaClientDiagnostics();
var before = d.Snapshot();
d.RecordKeepAliveMiss();
var id = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(id);
var d = new OpcUaClientDiagnostics(id);
before["KeepAliveMissCount"].ShouldBe(0);
d.Snapshot()["KeepAliveMissCount"].ShouldBe(1);
d.Record(OpcUaClientDiagnostics.Notification, 0);
d.Record(OpcUaClientDiagnostics.Notification, -2);
d.Record(OpcUaClientDiagnostics.DataChange); // positive control
cap[OpcUaClientDiagnostics.Notification].ShouldBe(0);
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
}
/// <summary>
/// #275: the driver does not split Read/Write/CreateMonitoredItems itself because the SDK session already
/// does — <c>Session</c> derives from <c>SessionClientBatched</c> (which batches by the server's
/// OperationLimits, fetched on open). If an SDK bump ever drops that, this goes red and the driver needs
/// its own chunking again.
/// </summary>
[Fact]
public void GetHealth_carries_the_diagnostics_and_keeps_state_fields()
public void The_sdk_session_is_the_batching_session_type()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-diag");
drv.DiagnosticsForTest.RecordKeepAliveMiss();
drv.DiagnosticsForTest.RecordKeepAliveMiss();
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Unknown);
h.Diagnostics.ShouldNotBeNull();
h.Diagnostics!["KeepAliveMissCount"].ShouldBe(2);
}
[Fact]
public void DriverHealth_without_diagnostics_stays_back_compatible()
{
var h = new DriverHealth(DriverState.Healthy, null, null);
h.Diagnostics.ShouldBeNull();
h.ShouldBe(new DriverHealth(DriverState.Healthy, null, null));
typeof(SessionClientBatched).IsAssignableFrom(typeof(Session)).ShouldBeTrue();
}
}
@@ -1,163 +0,0 @@
using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>#275 — the driver splits Read / Write calls to the server's declared OperationLimits.</summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientOperationLimitsTests
{
[Theory]
[InlineData(0, 0u, new int[0])] // nothing to send => no call
[InlineData(5, 0u, new[] { 5 })] // 0 = unlimited
[InlineData(5, 5u, new[] { 5 })] // exactly fits
[InlineData(5, 100u, new[] { 5 })] // limit larger than the batch
[InlineData(10, 4u, new[] { 4, 4, 2 })] // remainder chunk
[InlineData(9, 3u, new[] { 3, 3, 3 })]
[InlineData(3, 1u, new[] { 1, 1, 1 })]
public void Chunk_splits_to_the_limit(int total, uint limit, int[] expectedSizes)
{
var chunks = OpcUaClientOperationLimits.Chunk(total, limit).ToList();
chunks.Select(c => c.Count).ToArray().ShouldBe(expectedSizes);
// Contiguous cover of [0, total): every item exactly once, in order.
var next = 0;
foreach (var (start, count) in chunks) { start.ShouldBe(next); next += count; }
next.ShouldBe(total);
}
[Fact]
public void FromValues_reads_good_uints_and_treats_bad_or_missing_as_unlimited()
{
var values = new List<DataValue>
{
new(new Variant(100u)),
new(new Variant(50u)),
new(StatusCodes.BadNodeIdUnknown), // server doesn't publish MaxNodesPerBrowse
new(new Variant(7u)),
};
var limits = OpcUaClientOperationLimits.FromValues(values);
limits.MaxNodesPerRead.ShouldBe(100u);
limits.MaxNodesPerWrite.ShouldBe(50u);
limits.MaxNodesPerBrowse.ShouldBe(0u);
limits.MaxMonitoredItemsPerCall.ShouldBe(7u);
limits.MaxNodesPerHistoryReadData.ShouldBe(0u); // short list => 0
limits.MaxNodesPerHistoryReadEvents.ShouldBe(0u);
}
private static (OpcUaClientDriver Drv, Mock<ISession> Session, List<int> ReadSizes, List<int> WriteSizes) Rig(
uint readLimit, uint writeLimit, int tagCount)
{
var tags = Enumerable.Range(0, tagCount)
.Select(i => new RawTagEntry($"raw/t{i}", $$"""{"nodeId":"ns=2;s=T{{i}}"}""", WriteIdempotent: false))
.ToList();
var options = new OpcUaClientDriverOptions { RawTags = tags };
var drv = new OpcUaClientDriver(options, "opcua-limits");
drv.SetNamespaceMapForTest(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.NamespaceMap.FromTable(new NamespaceTable(), tags));
drv.SetOperationLimitsForTest(OpcUaClientOperationLimits.None with
{
MaxNodesPerRead = readLimit, MaxNodesPerWrite = writeLimit,
});
var readSizes = new List<int>();
var writeSizes = new List<int>();
var mock = new Mock<ISession>(MockBehavior.Loose);
mock.SetupGet(s => s.MessageContext)
.Returns(new ServiceMessageContext(SdkTelemetry.Context));
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mock.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
{
readSizes.Add(nodes.Count);
// Echo each node's identifier number back as the value so ordering across chunks is checkable.
var results = new DataValueCollection();
foreach (var n in nodes) results.Add(new DataValue(new Variant((string)n.NodeId.Identifier)));
return new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results };
});
mock.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((RequestHeader _, WriteValueCollection nodes, CancellationToken _) =>
{
writeSizes.Add(nodes.Count);
var codes = new StatusCodeCollection();
foreach (var _ in nodes) codes.Add(StatusCodes.Good);
return new WriteResponse { ResponseHeader = new ResponseHeader(), Results = codes };
});
drv.SetSessionForTest(mock.Object);
return (drv, mock, readSizes, writeSizes);
}
[Fact]
public async Task ReadAsync_splits_to_MaxNodesPerRead_and_keeps_result_order()
{
var (drv, _, readSizes, _) = Rig(readLimit: 4, writeLimit: 0, tagCount: 10);
using var _ = drv;
var refs = Enumerable.Range(0, 10).Select(i => $"raw/t{i}").ToList();
var res = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
readSizes.ShouldBe([4, 4, 2]);
res.Count.ShouldBe(10);
for (var i = 0; i < 10; i++) res[i].Value.ShouldBe($"T{i}"); // positional mapping survives chunking
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().Diagnostics!["ChunkedRequestCount"].ShouldBe(1);
}
[Fact]
public async Task ReadAsync_with_no_declared_limit_sends_one_request()
{
var (drv, _, readSizes, _) = Rig(readLimit: 0, writeLimit: 0, tagCount: 10);
using var _ = drv;
await drv.ReadAsync(Enumerable.Range(0, 10).Select(i => $"raw/t{i}").ToList(), TestContext.Current.CancellationToken);
readSizes.ShouldBe([10]);
}
[Fact]
public async Task ReadAsync_a_failing_chunk_only_fails_its_own_tags()
{
var (drv, session, _, _) = Rig(readLimit: 2, writeLimit: 0, tagCount: 4);
using var _ = drv;
var call = 0;
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.Returns((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
{
if (Interlocked.Increment(ref call) == 2) throw new ServiceResultException(StatusCodes.BadConnectionClosed);
var results = new DataValueCollection();
foreach (var n in nodes) results.Add(new DataValue(new Variant((string)n.NodeId.Identifier)));
return Task.FromResult(new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results });
});
var res = await drv.ReadAsync(["raw/t0", "raw/t1", "raw/t2", "raw/t3"], TestContext.Current.CancellationToken);
res[0].Value.ShouldBe("T0");
res[1].Value.ShouldBe("T1");
res[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError, chunk 2 only
res[3].StatusCode.ShouldBe(0x80050000u);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().Diagnostics!["ReadFailureCount"].ShouldBe(1);
}
[Fact]
public async Task WriteAsync_splits_to_MaxNodesPerWrite()
{
var (drv, _, _, writeSizes) = Rig(readLimit: 0, writeLimit: 3, tagCount: 7);
using var _ = drv;
var res = await drv.WriteAsync(
Enumerable.Range(0, 7).Select(i => new Core.Abstractions.WriteRequest($"raw/t{i}", i)).ToList(),
TestContext.Current.CancellationToken);
writeSizes.ShouldBe([3, 3, 1]);
res.Count.ShouldBe(7);
res.ShouldAllBe(r => r.StatusCode == 0u);
}
}
@@ -20,7 +20,6 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(0u);
o.SubscriptionPriority.ShouldBe((byte)0);
o.SubscriptionPublishingInterval.ShouldBeNull();
o.HonorServerOperationLimits.ShouldBeTrue();
o.DiscoveryUrl.ShouldBeNull();
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse();
o.CertificateValidation.IssuerStorePath.ShouldBeNull();
@@ -33,8 +32,7 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """
{"subscriptionPublishingInterval":"00:00:02","subscriptionKeepAliveCount":20,
"subscriptionLifetimeCount":600,"subscriptionMaxNotificationsPerPublish":500,
"subscriptionPriority":9,"honorServerOperationLimits":false,
"discoveryUrl":"opc.tcp://lds:4840",
"subscriptionPriority":9, "discoveryUrl":"opc.tcp://lds:4840","discoveryServerUri":"urn:plant:l1",
"certificateValidation":{"rejectUnknownRevocationStatus":true,"issuerStorePath":"/pki/issuers"}}
""");
@@ -43,8 +41,8 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
o.SubscriptionLifetimeCount.ShouldBe(600u);
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
o.SubscriptionPriority.ShouldBe((byte)9);
o.HonorServerOperationLimits.ShouldBeFalse();
o.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
o.DiscoveryServerUri.ShouldBe("urn:plant:l1");
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
o.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
@@ -119,21 +117,82 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
}
[Fact]
public void ResolveEndpointCandidates_prepends_discovered_urls_and_dedups_case_insensitively()
public void ResolveEndpointCandidates_puts_configured_endpoints_before_discovered_and_dedups()
{
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://Static:1", "opc.tcp://found:2"] };
var merged = OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://found:2", "opc.tcp://new:3"]);
merged.ShouldBe(["opc.tcp://found:2", "opc.tcp://new:3", "opc.tcp://Static:1"]);
merged.ShouldBe(["opc.tcp://Static:1", "opc.tcp://found:2", "opc.tcp://new:3"]);
}
[Fact]
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_after_discovered_when_no_list()
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_before_discovered_when_no_list()
{
var o = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:9" };
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://d:1", "opc.tcp://only:9"]);
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://only:9", "opc.tcp://d:1"]);
}
private static OpcUaClientDriver.DiscoveredEndpoint De(string appUri, string url) => new(appUri, url);
[Fact]
public void SelectDiscoveredUrls_with_one_distinct_server_uses_it_and_dedups()
{
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:a", "opc.tcp://a:1"), De("urn:a", "opc.tcp://A:1"), De("urn:a", "opc.tcp://a:2")], null);
urls.ShouldBe(["opc.tcp://a:1", "opc.tcp://a:2"]);
}
[Fact]
public void SelectDiscoveredUrls_with_several_servers_and_no_filter_fails_naming_them()
{
var ex = Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1")], null));
ex.Message.ShouldContain("urn:line1");
ex.Message.ShouldContain("urn:line2");
ex.Message.ShouldContain("DiscoveryServerUri");
}
[Fact]
public void SelectDiscoveredUrls_filter_picks_only_that_server_even_when_several_are_listed()
{
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1"), De("urn:line2", "opc.tcp://l2:2")],
"urn:line2");
urls.ShouldBe(["opc.tcp://l2:1", "opc.tcp://l2:2"]);
}
[Fact]
public void SelectDiscoveredUrls_filter_matching_nothing_yields_empty_not_another_server()
{
OpcUaClientDriver.SelectDiscoveredUrls([De("urn:line1", "opc.tcp://l1:1")], "urn:other").ShouldBeEmpty();
OpcUaClientDriver.SelectDiscoveredUrls([], null).ShouldBeEmpty();
}
// ---- #273: validation at options load ----
[Theory]
[InlineData("""{"subscriptionKeepAliveCount":0}""")]
[InlineData("""{"subscriptionKeepAliveCount":10,"subscriptionLifetimeCount":29}""")]
[InlineData("""{"discoveryUrl":"not a uri"}""")]
public void ParseOptions_rejects_out_of_range_tuning_with_a_clear_message(string json)
{
var ex = Should.Throw<InvalidOperationException>(
() => OpcUaClientDriverFactoryExtensions.ParseOptions("drv-bad", json));
ex.Message.ShouldContain("drv-bad");
ex.Message.ShouldContain("invalid");
}
[Fact]
public void Factory_CreateInstance_fails_fast_on_a_bad_tuning_config()
{
Should.Throw<InvalidOperationException>(() => OpcUaClientDriverFactoryExtensions.CreateInstance(
"drv-bad", """{"subscriptionLifetimeCount":5}"""));
}
[Fact]