fix(r2-10): successful capability calls reset ConsecutiveFailures

This commit is contained in:
Joseph Doherty
2026-07-13 10:38:51 -04:00
parent 2cce61fc04
commit 0ba60db1e4
3 changed files with 68 additions and 4 deletions
@@ -4,7 +4,7 @@
"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": "completed", "blockedBy": ["T1"] },
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "pending", "blockedBy": ["T1"] },
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "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"] },
{ "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "pending", "blockedBy": ["T5"] },
@@ -41,7 +41,7 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
/// pipeline-invalidate can take effect without restarting the invoker.
/// </param>
/// <param name="driverType">Driver type name for structured-log enrichment (e.g. <c>"Modbus"</c>).</param>
/// <param name="statusTracker">Optional resilience-status tracker. When wired, every capability call records start/complete so Admin <c>/hosts</c> can surface <see cref="ResilienceStatusSnapshot.CurrentInFlight"/> as the in-flight-call-depth proxy.</param>
/// <param name="statusTracker">Optional resilience-status tracker. When wired, every capability call records start/complete so the AdminUI resilience panel can surface <see cref="ResilienceStatusSnapshot.CurrentInFlight"/> as the in-flight-call-depth proxy, and each successful call resets <see cref="ResilienceStatusSnapshot.ConsecutiveFailures"/> so the counter reflects only an active retry storm.</param>
/// <param name="optionsGeneration">
/// Options epoch for this invoker (01/S-7). Threaded into every pipeline-cache lookup so a lingering
/// old child's late re-cache can never poison a newer invoker's pipeline. Defaults to 0 so existing
@@ -81,7 +81,11 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
{
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
{
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
var result = await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
// Success resets ConsecutiveFailures so the counter is a true "consecutive" gauge
// (nonzero only during an active retry storm), not stuck at the last blip's count.
_statusTracker?.RecordSuccess(_driverInstanceId, hostName, DateTime.UtcNow);
return result;
}
}
finally
@@ -106,6 +110,7 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
{
await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
_statusTracker?.RecordSuccess(_driverInstanceId, hostName, DateTime.UtcNow);
}
}
finally
@@ -139,7 +144,9 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
{
using (LogContextEnricher.Push(_driverInstanceId, _driverType, DriverCapability.Write, LogContextEnricher.NewCorrelationId()))
{
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
var result = await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
_statusTracker?.RecordSuccess(_driverInstanceId, hostName, DateTime.UtcNow);
return result;
}
}
finally
@@ -235,6 +235,63 @@ public sealed class CapabilityInvokerTests
builder.CachedPipelineCount.ShouldBe(1, "two writes to the same host share one cached no-retry pipeline");
}
/// <summary>
/// R2-10: a successful capability call must reset <c>ConsecutiveFailures</c> so the counter
/// is a true "consecutive" gauge — nonzero only during an active retry storm, not stuck at the
/// last blip's count forever. Previously only the breaker's <c>OnClosed</c> reset it, so a
/// transient 2-retry blip that never opened the breaker read "2 failures" indefinitely.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnSuccess_ResetsConsecutiveFailures()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow);
tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(2, "seeded failures");
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: tracker);
await invoker.ExecuteAsync(
DriverCapability.Read,
"host-1",
_ => ValueTask.FromResult(1),
CancellationToken.None);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(0,
"a successful capability call must clear the consecutive-failure counter");
}
/// <summary>
/// R2-10 companion: a successful non-idempotent write must also reset the consecutive-failure
/// counter on the caller's host (the write arm has its own success path).
/// </summary>
[Fact]
public async Task ExecuteWriteAsync_NonIdempotent_OnSuccess_ResetsConsecutiveFailures()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(1, "seeded failure");
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: tracker);
await invoker.ExecuteWriteAsync(
"host-1",
isIdempotent: false,
_ => ValueTask.FromResult(0),
CancellationToken.None);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(0,
"a successful non-idempotent write must clear the consecutive-failure counter");
}
/// <summary>
/// Core-009 regression — companion consistency assertion: the non-idempotent branch must
/// not observe two different option snapshots even if the accessor's returned value changes