fix(r2-02): factory stamps a per-Create options generation — closes the respawn stale-pipeline race (01/S-7)

This commit is contained in:
Joseph Doherty
2026-07-13 10:23:25 -04:00
parent 5daf7155a8
commit d8c6357be8
3 changed files with 61 additions and 10 deletions
@@ -161,6 +161,48 @@ public sealed class DriverCapabilityInvokerFactoryTests
(e.Message.Contains("timeoutSeconds") || e.Message.Contains("breakerFailureThreshold")));
}
/// <summary>
/// 01/S-7 production-wiring proof: a respawn interleaved with a lingering old-invoker call must still
/// apply the new options. The old invoker (a captured reference — exactly what the old child holds)
/// re-caches its stale pipeline AFTER the new <see cref="DriverCapabilityInvokerFactory.Create"/>'s
/// invalidate; the new invoker must nonetheless behave per the new config. Fails without the per-Create
/// generation stamp (both invokers share the (instance, host, capability) key and the stale entry wins).
/// </summary>
[Fact]
public async Task Respawn_interleaved_with_old_invoker_call_still_applies_new_options()
{
var builder = new DriverResiliencePipelineBuilder();
var factory = MakeFactory(builder);
// Old invoker: Read retries (tier-A default 3). Warm its cache.
var oldInvoker = factory.Create("d1", "Modbus", "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":3}}}");
await FailingRead(oldInvoker); // caches the retrying pipeline under the old generation
// Respawn with new options: Read no longer retries.
var newInvoker = factory.Create("d1", "Modbus", "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":0}}}");
// The lingering old child's late call lands AFTER the new Create's invalidate — re-caching the stale
// retrying pipeline under the OLD generation.
await FailingRead(oldInvoker);
// The new invoker must honor the new options (no retry) — a single attempt.
var attempts = 0;
await Should.ThrowAsync<InvalidOperationException>(async () =>
await newInvoker.ExecuteAsync(
DriverCapability.Read, "host-1",
async _ => { attempts++; await Task.Yield(); throw new InvalidOperationException("boom"); },
CancellationToken.None));
attempts.ShouldBe(1, "the new invoker must apply the new options (retryCount 0), not the stale re-cache");
}
private static async Task FailingRead(IDriverCapabilityInvoker invoker) =>
await Should.ThrowAsync<InvalidOperationException>(async () =>
await invoker.ExecuteAsync(
DriverCapability.Read, "host-1",
async _ => { await Task.Yield(); throw new InvalidOperationException("boom"); },
CancellationToken.None));
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();