fix(r2-10): OnClosed records breaker-close on the status tracker
This commit is contained in:
+53
@@ -277,6 +277,44 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-10 truthfulness fix: after the breaker opens and later recovers (a successful probe
|
||||
/// past <c>BreakDuration</c>), the pipeline builder's <c>OnClosed</c> hook must record the
|
||||
/// breaker close on the tracker — so <see cref="ResilienceStatusSnapshot.IsBreakerOpen"/>
|
||||
/// reads false again and <see cref="ResilienceStatusSnapshot.LastBreakerClosedUtc"/> is stamped.
|
||||
/// Uses a controllable <see cref="TimeProvider"/> so the 15 s break duration elapses deterministically.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tracker isolates counters per host.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A controllable <see cref="TimeProvider"/> whose clock advances only on <see cref="Advance"/>.
|
||||
/// Drives both wall-clock (<see cref="GetUtcNow"/>) and monotonic (<see cref="GetTimestamp"/>)
|
||||
/// reads off the same tick counter so Polly's circuit-breaker break-duration timing is deterministic.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Minimal in-memory <see cref="ILogger"/> capturing (level, formatted-message) pairs so the
|
||||
/// resilience logging contract can be asserted without a real logging provider.</summary>
|
||||
private sealed class CapturingLogger : ILogger
|
||||
|
||||
Reference in New Issue
Block a user