From 9213c037b00a5db98a46c78c9df89ccdf4d89e42 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 11:57:25 -0400 Subject: [PATCH] fix(drivers): wire PollGroupEngine onError->health + 30s poll backoff (05/STAB-9) [modbus,abcip,ablegacy] --- ...2-09-driver-fleet-batch-plan.md.tasks.json | 2 +- .../AbCipDriver.cs | 21 ++++++- .../AbLegacyDriver.cs | 21 ++++++- .../ModbusDriver.cs | 21 ++++++- .../AbCipPollErrorHealthTests.cs | 59 ++++++++++++++++++ .../AbLegacyPollErrorHealthTests.cs | 59 ++++++++++++++++++ .../ModbusPollErrorHealthTests.cs | 60 +++++++++++++++++++ 7 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPollErrorHealthTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyPollErrorHealthTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusPollErrorHealthTests.cs diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json index 886840b4..3dc69b81 100644 --- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json +++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json @@ -79,7 +79,7 @@ { "id": "B3.3", "subject": "B3: wire onError->health + 30s backoffCap into Modbus/AbCip/AbLegacy engine ctors (tests first per driver) \u2014 STAB-9", - "status": "pending", + "status": "completed", "blockedBy": [ "B3.2" ] diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index f95d344d..f55baf29 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -133,10 +133,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => - OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), + onError: HandlePollError, + backoffCap: PollBackoffCap); _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger); } + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). + private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); + + /// + /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and + /// degrades to preserving LastSuccessfulRead. + /// Never downgrades a state (a stronger, config-level signal). + /// + /// The exception caught by the poll engine. + internal void HandlePollError(Exception ex) + { + _logger.LogWarning(ex, "AbCip poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); + var current = _health; + if (current.State != DriverState.Faulted) + _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); + } + /// /// Fetch + cache the shape of a Logix UDT by template instance id. First call reads /// the Template Object off the controller; subsequent calls for the same diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 49115074..a45cd80a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -65,7 +65,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => - OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), + onError: HandlePollError, + backoffCap: PollBackoffCap); + } + + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). + private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); + + /// + /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and + /// degrades to preserving LastSuccessfulRead. + /// Never downgrades a state (a stronger, config-level signal). + /// + /// The exception caught by the poll engine. + internal void HandlePollError(Exception ex) + { + _logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); + var current = _health; + if (current.State != DriverState.Faulted) + _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index 16fe1934..227b1abc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -118,7 +118,26 @@ public sealed class ModbusDriver { if (!ShouldPublish(tagRef, snapshot)) return; OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)); - }); + }, + onError: HandlePollError, + backoffCap: PollBackoffCap); + } + + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). + private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); + + /// + /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and + /// degrades to preserving LastSuccessfulRead. + /// Never downgrades a state (a stronger, config-level signal). + /// + /// The exception caught by the poll engine. + internal void HandlePollError(Exception ex) + { + _logger.LogWarning(ex, "Modbus poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); + var current = ReadHealth(); + if (current.State != DriverState.Faulted) + WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPollErrorHealthTests.cs new file mode 100644 index 00000000..35df58e5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPollErrorHealthTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// 05/STAB-9 — the poll engine's onError sink must be wired to the driver health +/// surface: a poll-loop reader failure degrades health while preserving +/// , and must never downgrade a pre-existing +/// . +/// +[Trait("Category", "Unit")] +public sealed class AbCipPollErrorHealthTests +{ + /// A poll reader failure degrades health, preserving the last successful read timestamp. + [Fact] + public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead() + { + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbCipProbeOptions { Enabled = false }, + }; + var drv = new AbCipDriver(opts, "abcip-1", new FakeAbCipTagFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + var before = drv.GetHealth(); + before.State.ShouldBe(DriverState.Healthy); + before.LastSuccessfulRead.ShouldNotBeNull(); + + drv.HandlePollError(new InvalidOperationException("poll boom")); + + var after = drv.GetHealth(); + after.State.ShouldBe(DriverState.Degraded); + after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); + after.LastError.ShouldContain("poll boom"); + } + + /// A poll reader failure never downgrades a pre-existing Faulted state. + [Fact] + public async Task PollReaderFailure_NeverDowngradesFaulted() + { + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("not-a-valid-address")], + Probe = new AbCipProbeOptions { Enabled = false }, + }; + var drv = new AbCipDriver(opts, "abcip-1", new FakeAbCipTagFactory()); + + await Should.ThrowAsync(() => drv.InitializeAsync("{}", CancellationToken.None)); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + + drv.HandlePollError(new InvalidOperationException("poll boom")); + + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyPollErrorHealthTests.cs new file mode 100644 index 00000000..aebc691c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyPollErrorHealthTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// 05/STAB-9 — the poll engine's onError sink must be wired to the driver health +/// surface: a poll-loop reader failure degrades health while preserving +/// , and must never downgrade a pre-existing +/// . +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyPollErrorHealthTests +{ + /// A poll reader failure degrades health, preserving the last successful read timestamp. + [Fact] + public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead() + { + var opts = new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }; + var drv = new AbLegacyDriver(opts, "ablegacy-1", new FakeAbLegacyTagFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + var before = drv.GetHealth(); + before.State.ShouldBe(DriverState.Healthy); + before.LastSuccessfulRead.ShouldNotBeNull(); + + drv.HandlePollError(new InvalidOperationException("poll boom")); + + var after = drv.GetHealth(); + after.State.ShouldBe(DriverState.Degraded); + after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); + after.LastError.ShouldContain("poll boom"); + } + + /// A poll reader failure never downgrades a pre-existing Faulted state. + [Fact] + public async Task PollReaderFailure_NeverDowngradesFaulted() + { + var opts = new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("not-a-valid-address")], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }; + var drv = new AbLegacyDriver(opts, "ablegacy-1", new FakeAbLegacyTagFactory()); + + await Should.ThrowAsync(() => drv.InitializeAsync("{}", CancellationToken.None)); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + + drv.HandlePollError(new InvalidOperationException("poll boom")); + + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusPollErrorHealthTests.cs new file mode 100644 index 00000000..df86c6ac --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusPollErrorHealthTests.cs @@ -0,0 +1,60 @@ +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// 05/STAB-9 — the poll engine's onError sink must be wired to the driver health +/// surface: a poll-loop reader failure degrades health while preserving +/// , and must never downgrade a pre-existing +/// (the S7 nuance ported fleet-wide). +/// +[Trait("Category", "Unit")] +public sealed class ModbusPollErrorHealthTests +{ + private sealed class NoopTransport : IModbusTransport + { + public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; + public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + => Task.FromResult(new byte[] { 0x03, 0x00 }); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + /// A poll reader failure degrades health, preserving the last successful read timestamp. + [Fact] + public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead() + { + var opts = new ModbusDriverOptions { Host = "fake", Probe = new ModbusProbeOptions { Enabled = false } }; + var drv = new ModbusDriver(opts, "modbus-1", _ => new NoopTransport()); + await drv.InitializeAsync("{}", CancellationToken.None); + + var before = drv.GetHealth(); + before.State.ShouldBe(DriverState.Healthy); + before.LastSuccessfulRead.ShouldNotBeNull(); + + drv.HandlePollError(new InvalidOperationException("poll boom")); + + var after = drv.GetHealth(); + after.State.ShouldBe(DriverState.Degraded); + after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); + after.LastError.ShouldContain("poll boom"); + } + + /// A poll reader failure never downgrades a pre-existing Faulted state. + [Fact] + public async Task PollReaderFailure_NeverDowngradesFaulted() + { + var opts = new ModbusDriverOptions { Host = "fake", Probe = new ModbusProbeOptions { Enabled = false } }; + var drv = new ModbusDriver(opts, "modbus-1", _ => throw new SocketException()); + + // Init faults (the throwing factory) → Faulted, then rethrows. + await Should.ThrowAsync(() => drv.InitializeAsync("{}", CancellationToken.None)); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + + drv.HandlePollError(new InvalidOperationException("poll boom")); + + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + } +}