feat(archreview #13): apply per-instance ResilienceConfig from the deploy artifact

The DriverInstance.ResilienceConfig column was authored in AdminUI, persisted to the
entity, and serialized into the deployment artifact by ConfigComposer — but the runtime
read path dropped it: DriverInstanceSpec didn't carry it and the invoker factory always
passed null, so every driver got tier defaults regardless of its configured overrides
(a silent dead-config gap — #10's residual sub-finding).

Read-path plumbing (write side was already complete):
- DriverInstanceSpec gains ResilienceConfig; DeploymentArtifact.TryReadSpec reads the column.
- IDriverCapabilityInvokerFactory.Create takes resilienceConfigJson; DriverHostActor.SpawnChild
  threads spec.ResilienceConfig; the concrete factory parses it (ParseOrDefaults, layering on the
  tier), logs any parse diagnostic (never throws), and builds the invoker with the merged options.
- Invalidate-on-change: the pipeline cache keys on (instance, host, capability) and ignores options
  on a hit, so Create() now Invalidate()s the instance's cached pipelines first (no-op on first
  spawn) — a respawn with changed options rebuilds them.
- DriverSpawnPlanner treats a ResilienceConfig change as a stop+respawn (the invoker/options are
  bound to the child at spawn); a pure DriverConfig change stays an in-place delta (no reconnect).
- Host DI passes a logger to the factory for the parse diagnostic.

Verification (deterministic): factory Create applies a retryCount:0 override to actual execution
(control test proves tier default retries), invalidates the instance's cache on re-create (scoped —
sibling survives), malformed config logs+falls-back; planner respawns on ResilienceConfig change
(incl null→json) and stays delta on a pure config change; artifact parse carries/omits the column.
Core.Tests 243 (+5), Runtime.Tests 363 (+5), Host builds clean.
This commit is contained in:
Joseph Doherty
2026-07-08 22:48:17 -04:00
parent 62556c245a
commit 75403caa1a
9 changed files with 320 additions and 27 deletions
@@ -14,12 +14,20 @@ public interface IDriverCapabilityInvokerFactory
/// <summary>
/// Create the resilience invoker for one driver instance. The returned invoker is keyed on
/// <paramref name="driverInstanceId"/> and applies the tier policy resolved from
/// <paramref name="driverType"/>.
/// <paramref name="driverType"/>, layering any per-instance
/// <paramref name="resilienceConfigJson"/> overrides on top.
/// </summary>
/// <param name="driverInstanceId">The driver instance identifier (pipeline + tracker key).</param>
/// <param name="driverType">The driver type name, used to resolve the resilience tier defaults.</param>
/// <param name="resilienceConfigJson">
/// Optional per-instance <c>DriverInstance.ResilienceConfig</c> JSON (from the deployment artifact)
/// layered on top of the tier defaults. <c>null</c>/empty ⇒ pure tier defaults. A concrete factory
/// invalidates any cached pipelines for <paramref name="driverInstanceId"/> so a changed config
/// applied across a driver respawn takes effect (the pipeline cache is keyed on instance/host/
/// capability, not options).
/// </param>
/// <returns>A per-instance <see cref="IDriverCapabilityInvoker"/>.</returns>
IDriverCapabilityInvoker Create(string driverInstanceId, string driverType);
IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null);
}
/// <summary>
@@ -34,6 +42,6 @@ public sealed class NullDriverCapabilityInvokerFactory : IDriverCapabilityInvoke
private NullDriverCapabilityInvokerFactory() { }
/// <inheritdoc />
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType) =>
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null) =>
NullDriverCapabilityInvoker.Instance;
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
@@ -9,30 +10,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// resolves the abstraction from DI and never sees this type.
/// </summary>
/// <remarks>
/// <para>Resilience policy is currently the <b>tier default</b> for the driver type — resolved
/// via the injected tier resolver (backed by <c>DriverFactoryRegistry.GetTier</c>). The
/// per-instance <c>DriverInstance.ResilienceConfig</c> JSON override is NOT yet applied because
/// the deployment artifact (<c>DriverInstanceSpec</c>) does not carry it; plumbing that column
/// through the composer/artifact is a tracked follow-up. Until then every instance of a given
/// driver type gets that type's tier defaults.</para>
/// <para>Resilience policy is the driver type's <b>tier default</b> (resolved via the injected
/// tier resolver, backed by <c>DriverFactoryRegistry.GetTier</c>) with the per-instance
/// <c>DriverInstance.ResilienceConfig</c> JSON <b>layered on top</b> when the deployment artifact
/// carries one (<c>DriverInstanceSpec.ResilienceConfig</c>, threaded in by the Runtime
/// <c>DriverHostActor</c> at spawn). A missing/empty/malformed config degrades to pure tier
/// defaults — a bad ResilienceConfig never bricks a driver.</para>
///
/// <para>Options are snapshotted once per <see cref="Create"/> (tier is fixed for a driver type),
/// so the invoker's per-call options accessor is allocation-free on the hot path.</para>
/// <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
/// options accessor is allocation-free on the hot path. Because the pipeline builder caches
/// pipelines keyed on <c>(instance, host, capability)</c> and <b>ignores options on a cache hit</b>,
/// <see cref="Create"/> first <see cref="DriverResiliencePipelineBuilder.Invalidate"/>s any pipelines
/// cached for the instance so a respawn with changed options rebuilds them (rather than silently
/// reusing the stale pipeline). On a first spawn this removes nothing.</para>
/// </remarks>
public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory
{
private readonly DriverResiliencePipelineBuilder _builder;
private readonly DriverResilienceStatusTracker _statusTracker;
private readonly Func<string, DriverTier> _tierResolver;
private readonly ILogger? _logger;
/// <summary>Construct the factory over the shared resilience infrastructure.</summary>
/// <param name="builder">Process-singleton Polly pipeline builder (shared pipeline cache).</param>
/// <param name="statusTracker">Process-singleton resilience status tracker feeding Admin <c>/hosts</c>.</param>
/// <param name="tierResolver">Resolves a driver type name to its <see cref="DriverTier"/> (e.g. <c>DriverFactoryRegistry.GetTier</c>).</param>
/// <param name="logger">Optional logger; when non-null, a ResilienceConfig parse diagnostic (malformed JSON,
/// unknown capability, misapplied Tier-C recycle) is logged as a warning at <see cref="Create"/> time.</param>
public DriverCapabilityInvokerFactory(
DriverResiliencePipelineBuilder builder,
DriverResilienceStatusTracker statusTracker,
Func<string, DriverTier> tierResolver)
Func<string, DriverTier> tierResolver,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(statusTracker);
@@ -40,14 +50,26 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
_builder = builder;
_statusTracker = statusTracker;
_tierResolver = tierResolver;
_logger = logger;
}
/// <inheritdoc />
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType)
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null)
{
var tier = _tierResolver(driverType);
// No per-instance ResilienceConfig in the artifact yet — tier defaults only (see remarks).
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceConfigJson: null, out _);
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceConfigJson, out var diagnostic);
if (diagnostic is not null)
{
_logger?.LogWarning(
"Driver resilience config for instance={DriverInstanceId} type={DriverType}: {Diagnostic}",
driverInstanceId, driverType, diagnostic);
}
// Drop any pipelines cached for this instance under the PREVIOUS options — the builder keys on
// (instance, host, capability) and reuses a cached pipeline regardless of options, so a respawn
// with a changed ResilienceConfig would otherwise keep serving the stale pipeline. No-op on first spawn.
_builder.Invalidate(driverInstanceId);
return new CapabilityInvoker(
_builder,
driverInstanceId,