diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 2cd1f369..28331f08 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -357,7 +357,9 @@ Session/publish events are published on the shared OTel meter as the counter `keepalive_miss`, `keepalive_recovered` (a bad keep-alive followed by a good one on the same session), `stale_notification_dropped` (a late replay dropped inside the post-recovery window, see above). A climbing `keepalive_miss` / `keepalive_recovered` rate is the signature of a flapping upstream (`reconnect_*` were removed with -the reconnect handler, #670). They are not part of `DriverHealth` or the +the reconnect handler, #670). `publish_error` does not count the anonymous `BadNoSubscription` a server returns +while the driver tracks no subscription at all — the SDK keeps publishing on an idle session, so that is noise (#764). +They are not part of `DriverHealth` or the `/hosts` panel. ## Testing diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4ffb960d..d1bd8853 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -3377,7 +3377,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit } /// - /// Session PublishError: always counted; a BadNoSubscription / BadSubscriptionIdInvalid naming a + /// Session PublishError: counted, except an anonymous BadNoSubscription while the driver tracks no + /// subscription at all (#764) — the SDK keeps publishing on a session with none, and the server's answer is + /// idle noise, not an error. A BadNoSubscription / BadSubscriptionIdInvalid naming a /// subscription the driver tracks means the server dropped it while the session lived. An anonymous /// BadNoSubscription means the server holds none at all — a loss of anything tracked, except within /// of the driver deleting its last subscription (#763). @@ -3387,6 +3389,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit private void HandlePublishError(object? sender, StatusCode code, uint subscriptionId) { + // #764: with nothing tracked there is nothing to lose, and the SDK's publish loop keeps asking a session + // that holds no subscription — every answer is an anonymous BadNoSubscription. Counting those made + // publish_error climb on every idle driver; they are neither counted nor acted on. + if (code == StatusCodes.BadNoSubscription && subscriptionId == 0 + && _subscriptions.IsEmpty && _alarmSubscriptions.IsEmpty) + { + return; + } + _diag.Record(OpcUaClientDiagnostics.PublishError); var generation = Volatile.Read(ref _lifecycleGeneration); if (sender is not null && !ReferenceEquals(sender, Session)) return; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs new file mode 100644 index 00000000..e5a30ee6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +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; + +/// +/// Gitea #764 — the SDK keeps sending publish requests on a session that holds no subscription, and the server +/// answers each with an anonymous (id 0) BadNoSubscription. With nothing tracked there is nothing to lose, +/// so those answers must not inflate the publish_error counter. Any other PublishError still counts. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientIdlePublishErrorTests +{ + private const string SpeedRef = "Plant/dev/Speed"; + private const string AlarmRef = "Plant/dev/TempHi"; + + private static readonly IReadOnlyList Tags = + [ + new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true), + ]; + + private static OpcUaClientDriver NewConnectedDriver(string instance) + { + var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, instance); + var session = new Mock(MockBehavior.Loose); + session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + drv.SetSessionForTest(session.Object); + drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); + drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + return drv; + } + + [Fact] + public async Task An_anonymous_BadNoSubscription_on_an_idle_session_is_not_counted() + { + var instance = $"opcua-764-{Guid.NewGuid():N}"; + using var cap = new OpcUaClientDiagnosticsTests.Capture(instance); + var drv = NewConnectedDriver(instance); + await using var _ = drv; + + drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); + drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); + cap[OpcUaClientDiagnostics.PublishError].ShouldBe(0, "idle-session publish answers are not errors"); + + // Positive control: any other publish error on the same idle driver still counts. + drv.RaisePublishErrorForTest(StatusCodes.BadTooManyPublishRequests); + cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task An_anonymous_BadNoSubscription_with_a_tracked_subscription_is_still_counted() + { + var instance = $"opcua-764-{Guid.NewGuid():N}"; + using var cap = new OpcUaClientDiagnosticsTests.Capture(instance); + var drv = NewConnectedDriver(instance); + await using var _ = drv; + drv.AddAlarmSubscriptionForTest([AlarmRef]); + + drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription); + + cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1); + } +}