2a8f9af5e4
A bad keep-alive now degrades health (LastSuccessfulRead preserved) and, once per outage, publishes BadNoCommunication (value retained) for every subscribed ref plus a quality-only annotation on subscribed alarm conditions. Good keep-alives and data/event notifications advance LastSuccessfulRead, so an idle-but-alive subscription never looks dead to the #527 watchdog. A keep-alive that recovers on the same session re-reads the Bad refs and republishes them Good; a session that does not come back is recovered by the watchdog's ShutdownAsync + InitializeAsync + re-subscribe. Deletes the SessionReconnectHandler wiring (it retried forever in SDK 1.5.378, cloned subscriptions the driver never re-tracked and kept health Healthy), OnReconnectComplete, TryRearmReconnect and the reconnect_* counters (new keepalive_recovered). Teardown of a lost session skips wire deletes and bounds CloseSession. SDK warnings reach the driver log. Wire test reworked: restart => Bad, then watchdog-style reinit => Good.
128 lines
4.9 KiB
C#
128 lines
4.9 KiB
C#
using System.Diagnostics.Metrics;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <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
|
|
{
|
|
private sealed class Capture : IDisposable
|
|
{
|
|
private readonly MeterListener _listener = new();
|
|
private readonly object _gate = new();
|
|
private readonly Dictionary<string, long> _byEvent = new();
|
|
|
|
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_event_lands_on_its_own_tagged_series()
|
|
{
|
|
var id = $"diag-{Guid.NewGuid():N}";
|
|
using var cap = new Capture(id);
|
|
var d = new OpcUaClientDiagnostics(id);
|
|
|
|
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.KeepAliveRecovered);
|
|
|
|
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.KeepAliveRecovered].ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Another_instances_events_are_not_attributed_to_this_one()
|
|
{
|
|
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.Record(OpcUaClientDiagnostics.DataChange);
|
|
});
|
|
|
|
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(threads * each);
|
|
}
|
|
|
|
[Fact]
|
|
public void Zero_or_negative_counts_are_ignored()
|
|
{
|
|
var id = $"diag-{Guid.NewGuid():N}";
|
|
using var cap = new Capture(id);
|
|
var d = new OpcUaClientDiagnostics(id);
|
|
|
|
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 The_sdk_session_is_the_batching_session_type()
|
|
{
|
|
typeof(SessionClientBatched).IsAssignableFrom(typeof(Session)).ShouldBeTrue();
|
|
}
|
|
}
|