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
@@ -650,4 +650,39 @@ public sealed class DeploymentArtifactTests
var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E-c1" });
}
/// <summary>Verifies the per-instance ResilienceConfig column is carried onto the parsed spec.</summary>
[Fact]
public void ParseDriverInstances_carries_ResilienceConfig_onto_the_spec()
{
var resilienceJson = "{\"bulkheadMaxConcurrent\":16,\"capabilityPolicies\":{\"Write\":{\"retryCount\":0}}}";
var blob = BlobOf(new
{
DriverInstances = new[]
{
new { DriverInstanceId = "d1", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ResilienceConfig = resilienceJson },
},
});
var spec = DeploymentArtifact.ParseDriverInstances(blob).Single();
spec.ResilienceConfig.ShouldBe(resilienceJson);
}
/// <summary>Verifies an absent ResilienceConfig column leaves the spec field null (⇒ tier defaults at spawn).</summary>
[Fact]
public void ParseDriverInstances_absent_ResilienceConfig_is_null()
{
var blob = BlobOf(new
{
DriverInstances = new[]
{
new { DriverInstanceId = "d1", DriverType = "Modbus", Enabled = true, DriverConfig = "{}" },
},
});
var spec = DeploymentArtifact.ParseDriverInstances(blob).Single();
spec.ResilienceConfig.ShouldBeNull();
}
}
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public sealed class DriverSpawnPlannerTests
{
private static DriverInstanceSpec Spec(string id, string type = "Modbus", string config = "{\"host\":\"127.0.0.1\"}", bool enabled = true) =>
new(Guid.NewGuid(), id, id, type, enabled, config);
private static DriverInstanceSpec Spec(string id, string type = "Modbus", string config = "{\"host\":\"127.0.0.1\"}", bool enabled = true, string? resilience = null) =>
new(Guid.NewGuid(), id, id, type, enabled, config, ClusterId: null, ResilienceConfig: resilience);
/// <summary>Verifies that all new drivers are placed in ToSpawn when current is empty.</summary>
[Fact]
@@ -122,4 +122,66 @@ public sealed class DriverSpawnPlannerTests
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>
/// A ResilienceConfig change forces a stop+respawn (NOT an in-place delta): the CapabilityInvoker
/// and its resolved options are bound to the child at spawn, so the only way a changed config takes
/// effect is to rebuild the child (the respawn's Create call invalidates the stale cached pipelines).
/// </summary>
[Fact]
public void ResilienceConfig_change_triggers_stop_plus_respawn()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
// Same DriverType + same DriverConfig; only ResilienceConfig differs.
["a"] = new("Modbus", "{\"host\":\"127.0.0.1\"}", ResilienceConfig: "{\"bulkheadMaxConcurrent\":8}"),
};
var target = new[] { Spec("a", resilience: "{\"bulkheadMaxConcurrent\":32}") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>
/// Adding a ResilienceConfig where there was none (null → JSON) is also a respawn — the invoker
/// was built with tier defaults and must be rebuilt to pick up the overrides.
/// </summary>
[Fact]
public void Adding_ResilienceConfig_from_null_triggers_respawn()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{\"host\":\"127.0.0.1\"}", ResilienceConfig: null),
};
var target = new[] { Spec("a", resilience: "{\"bulkheadMaxConcurrent\":16}") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>
/// A pure DriverConfig change with an UNCHANGED ResilienceConfig stays an in-place delta (no
/// reconnect) — the resilience pipeline is untouched, so there's no reason to respawn.
/// </summary>
[Fact]
public void DriverConfig_change_with_unchanged_ResilienceConfig_stays_ApplyDelta()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{\"host\":\"old\"}", ResilienceConfig: "{\"bulkheadMaxConcurrent\":16}"),
};
var target = new[] { Spec("a", config: "{\"host\":\"new\"}", resilience: "{\"bulkheadMaxConcurrent\":16}") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a");
plan.ToSpawn.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
}