fix(r2-02): factory stamps a per-Create options generation — closes the respawn stale-pipeline race (01/S-7)
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
{ "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] },
|
{ "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] },
|
||||||
{ "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] },
|
{ "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] },
|
||||||
{ "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] },
|
{ "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] },
|
||||||
{ "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "pending", "blockedBy": [16] },
|
{ "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "completed", "blockedBy": [16] },
|
||||||
{ "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "pending", "blockedBy": [4, 8, 9, 12, 15, 17] }
|
{ "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "pending", "blockedBy": [4, 8, 9, 12, 15, 17] }
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-12"
|
"lastUpdated": "2026-07-12"
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|||||||
///
|
///
|
||||||
/// <para>Options are snapshotted once per <see cref="Create"/> (a driver actor's options are fixed
|
/// <para>Options are snapshotted once per <see cref="Create"/> (a driver actor's options are fixed
|
||||||
/// for its lifetime — a ResilienceConfig change respawns the child), so the invoker's per-call
|
/// for its lifetime — a ResilienceConfig change respawns the child), so the invoker's per-call
|
||||||
/// options accessor is allocation-free on the hot path. Because the pipeline builder caches
|
/// options accessor is allocation-free on the hot path. The pipeline builder caches pipelines keyed on
|
||||||
/// pipelines keyed on <c>(instance, host, capability)</c> and <b>ignores options on a cache hit</b>,
|
/// <c>(instance, host, capability, generation)</c> and ignores options on a cache hit; each
|
||||||
/// <see cref="Create"/> first <see cref="DriverResiliencePipelineBuilder.Invalidate"/>s any pipelines
|
/// <see cref="Create"/> stamps a fresh monotonic <b>generation</b> (01/S-7) so the new invoker can only
|
||||||
/// cached for the instance so a respawn with changed options rebuilds them (rather than silently
|
/// read pipelines it built — a lingering old child's post-invalidate re-cache lands under its own older
|
||||||
/// reusing the stale pipeline). On a first spawn this removes nothing.</para>
|
/// generation and is unreachable. <see cref="Create"/> still
|
||||||
|
/// <see cref="DriverResiliencePipelineBuilder.Invalidate"/>s the instance's pipelines as cleanup (bounds
|
||||||
|
/// the cache), but correctness now rests on the generation key, not the invalidate timing.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory
|
public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory
|
||||||
{
|
{
|
||||||
@@ -31,6 +33,7 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
|||||||
private readonly DriverResilienceStatusTracker _statusTracker;
|
private readonly DriverResilienceStatusTracker _statusTracker;
|
||||||
private readonly Func<string, DriverTier> _tierResolver;
|
private readonly Func<string, DriverTier> _tierResolver;
|
||||||
private readonly ILogger? _logger;
|
private readonly ILogger? _logger;
|
||||||
|
private long _generation;
|
||||||
|
|
||||||
/// <summary>Construct the factory over the shared resilience infrastructure.</summary>
|
/// <summary>Construct the factory over the shared resilience infrastructure.</summary>
|
||||||
/// <param name="builder">Process-singleton Polly pipeline builder (shared pipeline cache).</param>
|
/// <param name="builder">Process-singleton Polly pipeline builder (shared pipeline cache).</param>
|
||||||
@@ -65,9 +68,14 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
|||||||
driverInstanceId, driverType, diagnostic);
|
driverInstanceId, driverType, diagnostic);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop any pipelines cached for this instance under the PREVIOUS options — the builder keys on
|
// Stamp a fresh options generation for this invoker. The generation is part of the pipeline-cache
|
||||||
// (instance, host, capability) and reuses a cached pipeline regardless of options, so a respawn
|
// key (01/S-7), so this invoker can only ever read pipelines it built — a lingering old child's
|
||||||
// with a changed ResilienceConfig would otherwise keep serving the stale pipeline. No-op on first spawn.
|
// late re-cache lands under its OWN (older) generation and is unreachable here. Correctness rests on
|
||||||
|
// the epoch key, not on the timing of the invalidate below.
|
||||||
|
var generation = Interlocked.Increment(ref _generation);
|
||||||
|
|
||||||
|
// Cleanup (no longer the correctness mechanism): drop this instance's cached pipelines across all
|
||||||
|
// generations so lingering old-generation entries don't accumulate. No-op on first spawn.
|
||||||
_builder.Invalidate(driverInstanceId);
|
_builder.Invalidate(driverInstanceId);
|
||||||
|
|
||||||
return new CapabilityInvoker(
|
return new CapabilityInvoker(
|
||||||
@@ -75,6 +83,7 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
|||||||
driverInstanceId,
|
driverInstanceId,
|
||||||
optionsAccessor: () => options,
|
optionsAccessor: () => options,
|
||||||
driverType: driverType,
|
driverType: driverType,
|
||||||
statusTracker: _statusTracker);
|
statusTracker: _statusTracker,
|
||||||
|
optionsGeneration: generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -161,6 +161,48 @@ public sealed class DriverCapabilityInvokerFactoryTests
|
|||||||
(e.Message.Contains("timeoutSeconds") || e.Message.Contains("breakerFailureThreshold")));
|
(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
|
private sealed class CapturingLogger : ILogger
|
||||||
{
|
{
|
||||||
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||||
|
|||||||
Reference in New Issue
Block a user