diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs
index 2160f74b..6c28bdae 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs
@@ -14,12 +14,20 @@ public interface IDriverCapabilityInvokerFactory
///
/// Create the resilience invoker for one driver instance. The returned invoker is keyed on
/// and applies the tier policy resolved from
- /// .
+ /// , layering any per-instance
+ /// overrides on top.
///
/// The driver instance identifier (pipeline + tracker key).
/// The driver type name, used to resolve the resilience tier defaults.
+ ///
+ /// Optional per-instance DriverInstance.ResilienceConfig JSON (from the deployment artifact)
+ /// layered on top of the tier defaults. null/empty ⇒ pure tier defaults. A concrete factory
+ /// invalidates any cached pipelines for so a changed config
+ /// applied across a driver respawn takes effect (the pipeline cache is keyed on instance/host/
+ /// capability, not options).
+ ///
/// A per-instance .
- IDriverCapabilityInvoker Create(string driverInstanceId, string driverType);
+ IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null);
}
///
@@ -34,6 +42,6 @@ public sealed class NullDriverCapabilityInvokerFactory : IDriverCapabilityInvoke
private NullDriverCapabilityInvokerFactory() { }
///
- public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType) =>
+ public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null) =>
NullDriverCapabilityInvoker.Instance;
}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs
index 6c7f0039..dc9919f7 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs
@@ -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.
///
///
-/// Resilience policy is currently the tier default for the driver type — resolved
-/// via the injected tier resolver (backed by DriverFactoryRegistry.GetTier). The
-/// per-instance DriverInstance.ResilienceConfig JSON override is NOT yet applied because
-/// the deployment artifact (DriverInstanceSpec) 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.
+/// Resilience policy is the driver type's tier default (resolved via the injected
+/// tier resolver, backed by DriverFactoryRegistry.GetTier) with the per-instance
+/// DriverInstance.ResilienceConfig JSON layered on top when the deployment artifact
+/// carries one (DriverInstanceSpec.ResilienceConfig, threaded in by the Runtime
+/// DriverHostActor at spawn). A missing/empty/malformed config degrades to pure tier
+/// defaults — a bad ResilienceConfig never bricks a driver.
///
-/// Options are snapshotted once per (tier is fixed for a driver type),
-/// so the invoker's per-call options accessor is allocation-free on the hot path.
+/// Options are snapshotted once per (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 (instance, host, capability) and ignores options on a cache hit,
+/// first 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.
///
public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory
{
private readonly DriverResiliencePipelineBuilder _builder;
private readonly DriverResilienceStatusTracker _statusTracker;
private readonly Func _tierResolver;
+ private readonly ILogger? _logger;
/// Construct the factory over the shared resilience infrastructure.
/// Process-singleton Polly pipeline builder (shared pipeline cache).
/// Process-singleton resilience status tracker feeding Admin /hosts.
/// Resolves a driver type name to its (e.g. DriverFactoryRegistry.GetTier).
+ /// Optional logger; when non-null, a ResilienceConfig parse diagnostic (malformed JSON,
+ /// unknown capability, misapplied Tier-C recycle) is logged as a warning at time.
public DriverCapabilityInvokerFactory(
DriverResiliencePipelineBuilder builder,
DriverResilienceStatusTracker statusTracker,
- Func tierResolver)
+ Func 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;
}
///
- 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,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
index b2123bc7..bf6a5f37 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -66,7 +66,8 @@ public static class DriverFactoryBootstrap
return new DriverCapabilityInvokerFactory(
sp.GetRequiredService(),
sp.GetRequiredService(),
- registry.GetTier);
+ registry.GetTier,
+ logger: sp.GetService()?.CreateLogger("ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverCapabilityInvokerFactory"));
});
// Driver nodes also carry the probe set so a fused admin,driver node has it; the admin-only
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 41788a89..0178aaf8 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -18,7 +18,8 @@ public sealed record DriverInstanceSpec(
string DriverType,
bool Enabled,
string DriverConfig,
- string? ClusterId = null);
+ string? ClusterId = null,
+ string? ResilienceConfig = null);
/// How a node should scope a deployment artifact to its own ClusterId.
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);
}
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 2f258d98..a449d209 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -244,6 +244,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
public string DriverType => Spec.DriverType;
/// Gets the last applied driver configuration JSON from the child's spec.
public string LastConfigJson => Spec.DriverConfig;
+ /// Gets the per-instance ResilienceConfig JSON the child's invoker was built with (null = tier defaults).
+ public string? ResilienceConfig => Spec.ResilienceConfig;
}
/// Gets or sets the timer scheduler for this actor's config-db retry and rediscovery timers.
@@ -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!,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs
index 7f2207c8..5399691f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs
@@ -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
}
/// Snapshot of one running driver child as the host sees it. Used as the diff input.
-public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson);
+/// The driver type name the child was spawned for.
+/// The DriverConfig JSON the child currently serves.
+/// The per-instance ResilienceConfig JSON the child's invoker was built with (null = tier defaults).
+public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson, string? ResilienceConfig = null);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverCapabilityInvokerFactoryTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverCapabilityInvokerFactoryTests.cs
new file mode 100644
index 00000000..eb2c58d5
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverCapabilityInvokerFactoryTests.cs
@@ -0,0 +1,148 @@
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
+
+///
+/// Guards the per-instance ResilienceConfig plumbing (arch-review #13): the factory must layer the
+/// per-instance ResilienceConfig JSON onto the tier defaults, invalidate any stale cached
+/// pipelines for the instance on every (so a
+/// respawn with a changed config takes effect despite the options-blind pipeline cache), and log —
+/// never throw on — a malformed config.
+///
+[Trait("Category", "Unit")]
+public sealed class DriverCapabilityInvokerFactoryTests
+{
+ private static DriverCapabilityInvokerFactory MakeFactory(
+ DriverResiliencePipelineBuilder builder,
+ DriverTier tier = DriverTier.A,
+ ILogger? logger = null) =>
+ new(builder, new DriverResilienceStatusTracker(), _ => tier, logger);
+
+ ///
+ /// End-to-end proof the per-instance override REACHES execution: a ResilienceConfig that sets
+ /// Read's retryCount to 0 must suppress the tier-A Read retry — a failing Read is attempted once,
+ /// not retried.
+ ///
+ [Fact]
+ public async Task Create_applies_per_instance_ResilienceConfig_override_to_behaviour()
+ {
+ var builder = new DriverResiliencePipelineBuilder();
+ var factory = MakeFactory(builder);
+ // Tier A Read retries by default (see CapabilityInvokerTests.Read_Retries_OnTransientFailure);
+ // this override disables it.
+ var invoker = factory.Create("d1", "Modbus",
+ resilienceConfigJson: "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":0}}}");
+ var attempts = 0;
+
+ await Should.ThrowAsync(async () =>
+ await invoker.ExecuteAsync(
+ DriverCapability.Read,
+ "host-1",
+ async _ =>
+ {
+ attempts++;
+ await Task.Yield();
+ throw new InvalidOperationException("boom");
+ },
+ CancellationToken.None));
+
+ attempts.ShouldBe(1, "the per-instance ResilienceConfig override (retryCount:0) must suppress the tier-A Read retry");
+ }
+
+ ///
+ /// A control against the override test: with NO ResilienceConfig the same failing Read retries per
+ /// the tier-A default — confirming the previous test's single attempt is the override, not a fluke.
+ ///
+ [Fact]
+ public async Task Create_without_ResilienceConfig_uses_tier_default_retry()
+ {
+ var factory = MakeFactory(new DriverResiliencePipelineBuilder());
+ var invoker = factory.Create("d1", "Modbus");
+ var attempts = 0;
+
+ await Should.ThrowAsync(async () =>
+ await invoker.ExecuteAsync(
+ DriverCapability.Read,
+ "host-1",
+ async _ =>
+ {
+ attempts++;
+ await Task.Yield();
+ throw new InvalidOperationException("boom");
+ },
+ CancellationToken.None));
+
+ attempts.ShouldBeGreaterThan(1, "tier-A Read retries by default when no override disables it");
+ }
+
+ ///
+ /// Invalidate-on-change: because the pipeline cache is keyed on (instance, host, capability) and
+ /// ignores options on a hit, a fresh for an
+ /// instance must drop that instance's cached pipelines so a respawn with new options rebuilds them.
+ ///
+ [Fact]
+ public async Task Create_invalidates_prior_cached_pipelines_for_the_same_instance()
+ {
+ var builder = new DriverResiliencePipelineBuilder();
+ var factory = MakeFactory(builder);
+
+ // First invoker warms the cache for instance d1.
+ var first = factory.Create("d1", "Modbus");
+ await first.ExecuteAsync(DriverCapability.Read, "host-1", _ => ValueTask.FromResult(1), CancellationToken.None);
+ builder.CachedPipelineCount.ShouldBe(1);
+
+ // Re-creating the invoker for the SAME instance (a respawn) must invalidate the stale pipeline.
+ _ = factory.Create("d1", "Modbus", resilienceConfigJson: "{\"bulkheadMaxConcurrent\":8}");
+
+ builder.CachedPipelineCount.ShouldBe(0, "Create must invalidate the instance's cached pipelines so changed options take effect");
+ }
+
+ ///
+ /// Invalidation is scoped to the instance being (re)created — a sibling instance's warm pipeline
+ /// must survive.
+ ///
+ [Fact]
+ public async Task Create_does_not_invalidate_a_different_instance()
+ {
+ var builder = new DriverResiliencePipelineBuilder();
+ var factory = MakeFactory(builder);
+
+ var other = factory.Create("other", "Modbus");
+ await other.ExecuteAsync(DriverCapability.Read, "host-1", _ => ValueTask.FromResult(1), CancellationToken.None);
+ builder.CachedPipelineCount.ShouldBe(1);
+
+ _ = factory.Create("d1", "Modbus");
+
+ builder.CachedPipelineCount.ShouldBe(1, "invalidation is per-instance — the sibling's pipeline must remain");
+ }
+
+ /// A malformed ResilienceConfig logs a diagnostic but still yields a working (tier-default) invoker.
+ [Fact]
+ public async Task Create_with_malformed_ResilienceConfig_logs_and_falls_back_to_tier_defaults()
+ {
+ var logger = new CapturingLogger();
+ var factory = MakeFactory(new DriverResiliencePipelineBuilder(), logger: logger);
+
+ var invoker = factory.Create("d1", "Modbus", resilienceConfigJson: "{ this is not json ]");
+
+ invoker.ShouldNotBeNull();
+ // Still functional: a normal Read succeeds under the fallback tier defaults.
+ var result = await invoker.ExecuteAsync(DriverCapability.Read, "host-1", _ => ValueTask.FromResult(7), CancellationToken.None);
+ result.ShouldBe(7);
+ logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("resilience config", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public List<(LogLevel Level, string Message)> Entries { get; } = new();
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter) =>
+ Entries.Add((logLevel, formatter(state, exception)));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceInvokerFactoryRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceInvokerFactoryRegistrationTests.cs
new file mode 100644
index 00000000..6d6ec6b6
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceInvokerFactoryRegistrationTests.cs
@@ -0,0 +1,68 @@
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+using ZB.MOM.WW.OtOpcUa.Host.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Guards the production DI wiring for the Phase 6.1 resilience dispatch pipeline (arch-review #10 +
+/// #13). AddOtOpcUaDriverFactories (the driver-node bootstrap) must bind the real
+/// — not the
+/// pass-through — and that factory must mint a real per driver instance.
+/// This is the DI half of the #10 live gate: without it a refactor could silently drop back to the
+/// pass-through and the retry/breaker/bulkhead pipeline would go inert in production (the exact
+/// "built-but-never-wired" failure the whole review targets), while every unit test — which defaults to
+/// the pass-through — stays green.
+///
+public sealed class ResilienceInvokerFactoryRegistrationTests
+{
+ [Fact]
+ public void AddOtOpcUaDriverFactories_binds_the_real_invoker_factory_not_the_pass_through()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddOtOpcUaDriverFactories();
+
+ using var sp = services.BuildServiceProvider();
+ var factory = sp.GetRequiredService();
+
+ factory.ShouldBeOfType(
+ "the driver-node bootstrap must bind the real resilience factory — a NullDriverCapabilityInvokerFactory here means the pipeline is inert in production");
+ }
+
+ [Fact]
+ public void The_registered_factory_mints_a_real_CapabilityInvoker_per_instance()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddOtOpcUaDriverFactories();
+
+ using var sp = services.BuildServiceProvider();
+ var factory = sp.GetRequiredService();
+
+ var invoker = factory.Create("drv-1", "Modbus");
+
+ invoker.ShouldBeOfType(
+ "the real factory must produce a real CapabilityInvoker (not the NullDriverCapabilityInvoker pass-through)");
+ }
+
+ [Fact]
+ public void The_resilience_singletons_the_factory_depends_on_are_registered()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddOtOpcUaDriverFactories();
+
+ using var sp = services.BuildServiceProvider();
+
+ // The pipeline builder + status tracker are process singletons the factory closes over — the same
+ // instance must be shared so pipelines cache + telemetry aggregate across all driver instances.
+ sp.GetService().ShouldNotBeNull();
+ sp.GetService().ShouldNotBeNull();
+ sp.GetRequiredService()
+ .ShouldBeSameAs(sp.GetRequiredService(), "builder must be a singleton");
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
index 402175ea..f7a35041 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
@@ -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" });
}
+
+ /// Verifies the per-instance ResilienceConfig column is carried onto the parsed spec.
+ [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);
+ }
+
+ /// Verifies an absent ResilienceConfig column leaves the spec field null (⇒ tier defaults at spawn).
+ [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();
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs
index e0d8d72e..f9e18d4a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs
@@ -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);
/// Verifies that all new drivers are placed in ToSpawn when current is empty.
[Fact]
@@ -122,4 +122,66 @@ public sealed class DriverSpawnPlannerTests
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
plan.ToApplyDelta.ShouldBeEmpty();
}
+
+ ///
+ /// 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).
+ ///
+ [Fact]
+ public void ResilienceConfig_change_triggers_stop_plus_respawn()
+ {
+ var current = new Dictionary
+ {
+ // 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void Adding_ResilienceConfig_from_null_triggers_respawn()
+ {
+ var current = new Dictionary
+ {
+ ["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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void DriverConfig_change_with_unchanged_ResilienceConfig_stays_ApplyDelta()
+ {
+ var current = new Dictionary
+ {
+ ["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();
+ }
}