fix(r2-02): options-generation in the pipeline cache key — stale respawn re-cache unreachable (01/S-7, 03/S13)

This commit is contained in:
Joseph Doherty
2026-07-13 10:22:07 -04:00
parent 112f88c9fe
commit 5daf7155a8
4 changed files with 76 additions and 8 deletions
@@ -356,6 +356,54 @@ public sealed class DriverResiliencePipelineBuilderTests
}
}
/// <summary>
/// 01/S-7 = 03/S13: a stale-epoch re-cache must not be served to a new generation. Deterministic
/// replay of the respawn race — the old child's late <c>GetOrCreate</c> re-caches under generation 1
/// AFTER the invalidate; the new invoker (generation 2) with new options must build + serve a pipeline
/// honoring the NEW options, not the poisoned old-generation entry. Generations make the interleaving
/// expressible sequentially — no timing dependence.
/// </summary>
[Fact]
public async Task StaleGeneration_ReCache_IsNotServed_ToNewGeneration()
{
var builder = new DriverResiliencePipelineBuilder();
var optsA = new DriverResilienceOptions
{
Tier = DriverTier.A,
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Read] = new(TimeoutSeconds: 5, RetryCount: 3, BreakerFailureThreshold: 0),
},
};
var optsB = new DriverResilienceOptions
{
Tier = DriverTier.A,
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Read] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 0),
},
};
// gen 1 warms the cache with the retrying optsA.
builder.GetOrCreate("i", "h", DriverCapability.Read, optsA, optionsGeneration: 1);
builder.Invalidate("i");
// The lingering old child's late call re-caches optsA under its OWN generation 1.
builder.GetOrCreate("i", "h", DriverCapability.Read, optsA, optionsGeneration: 1);
// The new invoker (gen 2, optsB) must get a pipeline honoring optsB (no retry), not the poisoned one.
var newPipeline = builder.GetOrCreate("i", "h", DriverCapability.Read, optsB, optionsGeneration: 2);
var attempts = 0;
await Should.ThrowAsync<InvalidOperationException>(async () =>
await newPipeline.ExecuteAsync(async _ =>
{
attempts++;
await Task.Yield();
throw new InvalidOperationException("boom");
}));
attempts.ShouldBe(1, "the new generation must honor optsB (retryCount 0), not the stale optsA re-cache");
}
/// <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