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:
@@ -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": 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": 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": 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": 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": 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] },
|
{ "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 string _driverType;
|
||||||
private readonly Func<DriverResilienceOptions> _optionsAccessor;
|
private readonly Func<DriverResilienceOptions> _optionsAccessor;
|
||||||
private readonly DriverResilienceStatusTracker? _statusTracker;
|
private readonly DriverResilienceStatusTracker? _statusTracker;
|
||||||
|
private DriverResilienceOptions? _noRetryWriteOptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Construct an invoker for one driver instance.
|
/// Construct an invoker for one driver instance.
|
||||||
@@ -116,27 +117,44 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
|
|||||||
|
|
||||||
if (!isIdempotent)
|
if (!isIdempotent)
|
||||||
{
|
{
|
||||||
// Snapshot the options exactly once per call — invoking _optionsAccessor twice can
|
// Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are
|
||||||
// (a) observe two different snapshots if an Admin edit lands between them and
|
// immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child,
|
||||||
// (b) wastes an allocation on the per-write hot path (Phase 6.1 1% pipeline budget).
|
// which constructs a fresh invoker (#13 respawn-on-change) — so lazily caching the snapshot is
|
||||||
var snapshot = _optionsAccessor();
|
// safe and removes the per-write allocation the previous once-per-call snapshot incurred. The
|
||||||
var noRetryOptions = snapshot with
|
// null-check-assign tolerates a benign first-call race (both threads build an equivalent record).
|
||||||
{
|
var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions();
|
||||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
|
||||||
{
|
|
||||||
[DriverCapability.Write] = snapshot.Resolve(DriverCapability.Write) with { RetryCount = 0 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var pipeline = _builder.GetOrCreate(_driverInstanceId, $"{hostName}::non-idempotent", DriverCapability.Write, noRetryOptions);
|
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);
|
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) =>
|
private ResiliencePipeline ResolvePipeline(DriverCapability capability, string hostName) =>
|
||||||
_builder.GetOrCreate(_driverInstanceId, hostName, capability, _optionsAccessor());
|
_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");
|
"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>
|
/// <summary>
|
||||||
/// Core-009 regression — companion consistency assertion: the non-idempotent branch must
|
/// 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
|
/// not observe two different option snapshots even if the accessor's returned value changes
|
||||||
|
|||||||
Reference in New Issue
Block a user