099bc1b6e0
The F2 live gate failed (10 value regressions over 4 blips): IsLateReplay disarmed a ref on its first in-order notification, which arrives ~1 s after recovery, while the SDK's late replay lands 4-6 s later. Equal source times also passed, so a replay equal to the recovery read got through. While the window is open each ref now carries a high-water mark (seeded at arming, raised by the recovery read, advanced by every accepted notification); a notification at or below it is dropped (equal included — OPC UA moves the source timestamp on every value/status change). Null source time passes without moving the mark. Window expiry still disarms entirely, so an upstream clock step-back is never frozen outside the window. Arm/expiry serialised by a lock so an expiry racing a re-arm cannot wipe fresh seeds.
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
|
|
{
|
|
internal 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();
|
|
}
|
|
}
|