diff --git a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json index 0bba16d4..f6048cd0 100644 --- a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json +++ b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json @@ -3,7 +3,7 @@ "lastUpdated": "2026-07-12", "tasks": [ { "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "pending", "blockedBy": ["T1"] }, + { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] }, { "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "pending", "blockedBy": ["T1"] }, { "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "pending", "blockedBy": [] }, { "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "pending", "blockedBy": ["T1", "T4"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs index 2a6b3952..fe5b6cff 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs @@ -38,14 +38,16 @@ public sealed class DriverResiliencePipelineBuilder /// Clock source for pipeline timeouts + breaker sampling. Defaults to system. /// When non-null, every built pipeline wires Polly telemetry into /// the tracker — retries increment ConsecutiveFailures, breaker-open stamps - /// LastBreakerOpenUtc, breaker-close resets failures. Feeds Admin /hosts + + /// LastBreakerOpenUtc, breaker-close resets failures and stamps + /// LastBreakerClosedUtc (so IsBreakerOpen reads false again). Feeds the AdminUI + /// resilience panel via the driver-resilience-status DPS publisher + /// the in-flight-call-depth column. Absent tracker means no telemetry (unit tests + /// deployments that don't care about resilience observability). /// When non-null, retry / circuit-breaker-open / breaker-close events are - /// logged (instance + host + capability + attempt/exception). This is the operator-facing - /// observability surface for the resilience pipeline until the Admin /hosts reader - /// (Phase 6.1 Stream E.2/E.3) ships — without it the pipeline runs silently. Absent logger = - /// no resilience logging (unit tests). + /// logged (instance + host + capability + attempt/exception). This is the structured-log + /// drill-down for the resilience pipeline, complementing the AdminUI resilience panel fed by + /// the tracker — without it the pipeline runs silently. Absent logger = no resilience logging + /// (unit tests). public DriverResiliencePipelineBuilder( TimeProvider? timeProvider = null, DriverResilienceStatusTracker? statusTracker = null, @@ -186,8 +188,11 @@ public sealed class DriverResiliencePipelineBuilder breakerOptions.OnClosed = args => { // Closing the breaker means the target recovered — reset the consecutive- - // failure counter so Admin UI stops flashing red for this host. - tracker?.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + // failure counter AND stamp the close time so IsBreakerOpen reads false again + // (the resilience panel stops showing "Breaker OPEN" for this host). + var closedUtc = timeProvider.GetUtcNow().UtcDateTime; + tracker?.RecordSuccess(driverInstanceId, hostName, closedUtc); + tracker?.RecordBreakerClose(driverInstanceId, hostName, closedUtc); logger?.LogInformation( "Driver resilience circuit-breaker closed (recovered): instance={DriverInstanceId} host={HostName} capability={Capability}", driverInstanceId, hostName, capability); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs index b9bebed5..44642c28 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs @@ -277,6 +277,44 @@ public sealed class DriverResiliencePipelineBuilderTests snap!.LastBreakerOpenUtc.ShouldNotBeNull(); } + /// + /// R2-10 truthfulness fix: after the breaker opens and later recovers (a successful probe + /// past BreakDuration), the pipeline builder's OnClosed hook must record the + /// breaker close on the tracker — so + /// reads false again and is stamped. + /// Uses a controllable so the 15 s break duration elapses deterministically. + /// + [Fact] + public async Task Tracker_RecordsBreakerClose_OnRecovery() + { + var clock = new ManualTimeProvider(new DateTime(2026, 7, 13, 0, 0, 0, DateTimeKind.Utc)); + var tracker = new DriverResilienceStatusTracker(); + var builder = new DriverResiliencePipelineBuilder(timeProvider: clock, statusTracker: tracker); + var pipeline = builder.GetOrCreate("drv-trk", "host-c", DriverCapability.Write, TierAOptions); + + // Drive the breaker open with the tier's throughput of failures. + var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold; + for (var i = 0; i < threshold; i++) + { + await Should.ThrowAsync(async () => + await pipeline.ExecuteAsync(async _ => + { + await Task.Yield(); + throw new InvalidOperationException("boom"); + })); + } + + tracker.TryGet("drv-trk", "host-c")!.IsBreakerOpen.ShouldBeTrue("breaker must be open after the failure threshold"); + + // Elapse the break duration (15 s) so the breaker half-opens, then let a probe succeed → OnClosed. + clock.Advance(TimeSpan.FromSeconds(16)); + await pipeline.ExecuteAsync(async _ => await Task.Yield()); + + var snap = tracker.TryGet("drv-trk", "host-c")!; + snap.LastBreakerClosedUtc.ShouldNotBeNull("OnClosed must stamp the close time on recovery"); + snap.IsBreakerOpen.ShouldBeFalse("the breaker must read closed after a successful recovery probe"); + } + /// Verifies that the tracker isolates counters per host. [Fact] public async Task Tracker_IsolatesCounters_PerHost() @@ -404,6 +442,21 @@ public sealed class DriverResiliencePipelineBuilderTests attempts.ShouldBe(1, "the new generation must honor optsB (retryCount 0), not the stale optsA re-cache"); } + /// + /// A controllable whose clock advances only on . + /// Drives both wall-clock () and monotonic () + /// reads off the same tick counter so Polly's circuit-breaker break-duration timing is deterministic. + /// + private sealed class ManualTimeProvider : TimeProvider + { + private long _ticks; + public ManualTimeProvider(DateTime start) => _ticks = start.Ticks; + public void Advance(TimeSpan by) => _ticks += by.Ticks; + public override DateTimeOffset GetUtcNow() => new(_ticks, TimeSpan.Zero); + public override long GetTimestamp() => _ticks; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + } + /// Minimal in-memory capturing (level, formatted-message) pairs so the /// resilience logging contract can be asserted without a real logging provider. private sealed class CapturingLogger : ILogger