From b494d09d9b684ec4bef54b283fc4986f86883349 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 11:59:49 -0400 Subject: [PATCH] fix(drivers): wire PollGroupEngine onError->health + 30s poll backoff (05/STAB-9) [twincat,focas] --- ...2-09-driver-fleet-batch-plan.md.tasks.json | 2 +- .../FocasDriver.cs | 22 ++++++- .../TwinCATDriver.cs | 21 ++++++- .../FocasPollErrorHealthTests.cs | 58 ++++++++++++++++++ .../TwinCATPollErrorHealthTests.cs | 61 +++++++++++++++++++ 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATPollErrorHealthTests.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 3dc69b81..12fda5e7 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 @@ -87,7 +87,7 @@ { "id": "B3.4", "subject": "B3: wire onError->health + 30s backoffCap into TwinCAT/FOCAS 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.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index e964ca94..4e64e2bd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -72,7 +72,27 @@ public sealed class FocasDriver : 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); + } + + /// 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, "FOCAS poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); + var current = Volatile.Read(ref _health); + if (current.State != DriverState.Faulted) + Volatile.Write(ref _health, + new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 4748f844..1fd23a1b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -69,7 +69,26 @@ public sealed class TwinCATDriver : 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); + } + + /// 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, "TwinCAT 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/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs new file mode 100644 index 00000000..84a9da76 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs @@ -0,0 +1,58 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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 FocasPollErrorHealthTests +{ + /// A poll reader failure degrades health, preserving the last successful read timestamp. + [Fact] + public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead() + { + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-1", new FakeFocasClientFactory()); + 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 drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte)], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-1", new FakeFocasClientFactory()); + + 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.TwinCAT.Tests/TwinCATPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATPollErrorHealthTests.cs new file mode 100644 index 00000000..1f97f399 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATPollErrorHealthTests.cs @@ -0,0 +1,61 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.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 TwinCATPollErrorHealthTests +{ + private const string Host = "ads://5.23.91.23.1.1:851"; + + /// A poll reader failure degrades health, preserving the last successful read timestamp. + [Fact] + public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead() + { + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = false, + }, "twincat-1", new FakeTwinCATClientFactory()); + 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 drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("not-a-valid-address")], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = false, + }, "twincat-1", new FakeTwinCATClientFactory()); + + 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); + } +}