fix(r2-02): non-idempotent write arm — cache the no-retry snapshot (01/P-4) and record tracker in-flight accounting (S-8 residual)

This commit is contained in:
Joseph Doherty
2026-07-13 10:05:52 -04:00
parent 496f97da20
commit 20ff67bf9a
3 changed files with 79 additions and 14 deletions
@@ -9,7 +9,7 @@
{ "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] },
{ "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "completed", "blockedBy": [2] },
{ "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "completed", "blockedBy": [6] },
{ "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "pending", "blockedBy": [7] },
{ "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "completed", "blockedBy": [7] },
{ "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "pending", "blockedBy": [7, 5] },
{ "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "pending", "blockedBy": [2, 6] },
{ "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "pending", "blockedBy": [5, 10] },
@@ -28,6 +28,7 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
private readonly string _driverType;
private readonly Func<DriverResilienceOptions> _optionsAccessor;
private readonly DriverResilienceStatusTracker? _statusTracker;
private DriverResilienceOptions? _noRetryWriteOptions;
/// <summary>
/// Construct an invoker for one driver instance.
@@ -116,27 +117,44 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
if (!isIdempotent)
{
// Snapshot the options exactly once per call — invoking _optionsAccessor twice can
// (a) observe two different snapshots if an Admin edit lands between them and
// (b) wastes an allocation on the per-write hot path (Phase 6.1 1% pipeline budget).
var snapshot = _optionsAccessor();
var noRetryOptions = snapshot with
{
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Write] = snapshot.Resolve(DriverCapability.Write) with { RetryCount = 0 },
},
};
// Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are
// immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child,
// which constructs a fresh invoker (#13 respawn-on-change) — so lazily caching the snapshot is
// safe and removes the per-write allocation the previous once-per-call snapshot incurred. The
// null-check-assign tolerates a benign first-call race (both threads build an equivalent record).
var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions();
var pipeline = _builder.GetOrCreate(_driverInstanceId, $"{hostName}::non-idempotent", DriverCapability.Write, noRetryOptions);
using (LogContextEnricher.Push(_driverInstanceId, _driverType, DriverCapability.Write, LogContextEnricher.NewCorrelationId()))
// Record in-flight accounting on the CALLER's host (the "::non-idempotent" suffix is a
// pipeline-cache key, not an operator-facing host) so Admin /hosts sees write depth too.
_statusTracker?.RecordCallStart(_driverInstanceId, hostName);
try
{
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
using (LogContextEnricher.Push(_driverInstanceId, _driverType, DriverCapability.Write, LogContextEnricher.NewCorrelationId()))
{
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_statusTracker?.RecordCallComplete(_driverInstanceId, hostName);
}
}
return await ExecuteAsync(DriverCapability.Write, hostName, callSite, cancellationToken).ConfigureAwait(false);
}
private DriverResilienceOptions BuildNoRetryWriteOptions()
{
var snapshot = _optionsAccessor();
return snapshot with
{
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Write] = snapshot.Resolve(DriverCapability.Write) with { RetryCount = 0 },
},
};
}
private ResiliencePipeline ResolvePipeline(DriverCapability capability, string hostName) =>
_builder.GetOrCreate(_driverInstanceId, hostName, capability, _optionsAccessor());
}
@@ -188,6 +188,53 @@ public sealed class CapabilityInvokerTests
"ExecuteWriteAsync's non-idempotent branch must capture the options snapshot exactly once per call");
}
/// <summary>
/// S-8 residual: the non-idempotent write arm must record tracker in-flight accounting (start +
/// complete) on the caller's host, exactly like the idempotent arms — the previous arm skipped it.
/// </summary>
[Fact]
public async Task NonIdempotentWrite_RecordsTrackerStartAndComplete()
{
var tracker = new DriverResilienceStatusTracker();
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);
var snap = tracker.TryGet("drv-test", "host-1");
snap.ShouldNotBeNull("the non-idempotent write arm must record tracker accounting on the caller's host");
snap!.CurrentInFlight.ShouldBe(0, "in-flight must return to 0 after the write completes");
}
/// <summary>
/// 01/P-4: the non-idempotent write arm must build the no-retry options snapshot ONCE per invoker
/// lifetime (not per call). Two writes to the same host reuse the cached snapshot — the options
/// accessor is invoked at most once and only one pipeline is cached.
/// </summary>
[Fact]
public async Task NonIdempotentWrite_ReusesCachedNoRetryOptions()
{
var builder = new DriverResiliencePipelineBuilder();
var accessorCalls = 0;
var invoker = new CapabilityInvoker(
builder,
"drv-test",
() => { Interlocked.Increment(ref accessorCalls); return new DriverResilienceOptions { Tier = DriverTier.A }; });
await invoker.ExecuteWriteAsync("host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None);
await invoker.ExecuteWriteAsync("host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None);
accessorCalls.ShouldBeLessThanOrEqualTo(1, "the no-retry snapshot must be cached across calls");
builder.CachedPipelineCount.ShouldBe(1, "two writes to the same host share one cached no-retry pipeline");
}
/// <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