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
@@ -18,7 +18,8 @@ public sealed record DriverInstanceSpec(
string DriverType,
bool Enabled,
string DriverConfig,
string? ClusterId = null);
string? ClusterId = null,
string? ResilienceConfig = null);
/// <summary>How a node should scope a deployment artifact to its own ClusterId.</summary>
public enum ClusterFilterMode
@@ -167,6 +168,9 @@ public static class DeploymentArtifact
|| enEl.GetBoolean();
var config = ReadString(el, "DriverConfig");
var clusterId = ReadString(el, "ClusterId");
// Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable —
// absent means tier defaults; layered onto the tier by DriverResilienceOptionsParser at spawn.
var resilienceConfig = ReadString(el, "ResilienceConfig");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
@@ -177,7 +181,8 @@ public static class DeploymentArtifact
DriverType: type!,
Enabled: enabled,
DriverConfig: config ?? "{}",
ClusterId: clusterId);
ClusterId: clusterId,
ResilienceConfig: resilienceConfig);
}
/// <summary>
@@ -244,6 +244,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
public string DriverType => Spec.DriverType;
/// <summary>Gets the last applied driver configuration JSON from the child's spec.</summary>
public string LastConfigJson => Spec.DriverConfig;
/// <summary>Gets the per-instance ResilienceConfig JSON the child's invoker was built with (null = tier defaults).</summary>
public string? ResilienceConfig => Spec.ResilienceConfig;
}
/// <summary>Gets or sets the timer scheduler for this actor's config-db retry and rediscovery timers.</summary>
@@ -1269,7 +1271,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var specs = DeploymentArtifact.ParseDriverInstances(blob, _localNode.Value);
var snapshots = _children.ToDictionary(
kv => kv.Key,
kv => new DriverChildSnapshot(kv.Value.DriverType, kv.Value.LastConfigJson),
kv => new DriverChildSnapshot(kv.Value.DriverType, kv.Value.LastConfigJson, kv.Value.ResilienceConfig),
StringComparer.Ordinal);
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
@@ -1625,9 +1627,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
else
{
// Attach the per-instance Phase 6.1 resilience invoker so this driver's capability calls
// route through the retry/breaker/bulkhead/telemetry pipeline. The pass-through factory
// yields a no-op invoker on nodes without the real resilience factory bound.
var invoker = _invokerFactory.Create(spec.DriverInstanceId, spec.DriverType);
// route through the retry/breaker/bulkhead/telemetry pipeline. The tier defaults are layered
// with the per-instance ResilienceConfig from the artifact; the pass-through factory yields a
// no-op invoker on nodes without the real resilience factory bound.
var invoker = _invokerFactory.Create(spec.DriverInstanceId, spec.DriverType, spec.ResilienceConfig);
child = Context.ActorOf(
DriverInstanceActor.Props(
driver!,
@@ -42,8 +42,14 @@ public static class DriverSpawnPlanner
toStop.Add(id);
continue;
}
// Driver type changes can't be reinitialized in-place (factory-bound) — stop + respawn.
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal))
// A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn.
// A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its
// resolved options) is bound to the child actor at spawn time, so the only way a changed
// ResilienceConfig takes effect is to rebuild the child. The factory invalidates the stale
// cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an
// in-place delta — no reconnect — because it doesn't touch the resilience pipeline.)
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal)
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal))
{
toStop.Add(id);
toSpawn.Add(spec);
@@ -67,4 +73,7 @@ public static class DriverSpawnPlanner
}
/// <summary>Snapshot of one running driver child as the host sees it. Used as the diff input.</summary>
public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson);
/// <param name="DriverType">The driver type name the child was spawned for.</param>
/// <param name="LastConfigJson">The <c>DriverConfig</c> JSON the child currently serves.</param>
/// <param name="ResilienceConfig">The per-instance <c>ResilienceConfig</c> JSON the child's invoker was built with (null = tier defaults).</param>
public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson, string? ResilienceConfig = null);