From 75403caa1a7926db9ae1e7941b77c7efedb65d96 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 8 Jul 2026 22:48:17 -0400 Subject: [PATCH] feat(archreview #13): apply per-instance ResilienceConfig from the deploy artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../IDriverCapabilityInvokerFactory.cs | 14 +- .../DriverCapabilityInvokerFactory.cs | 46 ++++-- .../Drivers/DriverFactoryBootstrap.cs | 3 +- .../Drivers/DeploymentArtifact.cs | 9 +- .../Drivers/DriverHostActor.cs | 11 +- .../Drivers/DriverSpawnPlan.cs | 15 +- .../DriverCapabilityInvokerFactoryTests.cs | 148 ++++++++++++++++++ .../Drivers/DeploymentArtifactTests.cs | 35 +++++ .../Drivers/DriverSpawnPlannerTests.cs | 66 +++++++- 9 files changed, 320 insertions(+), 27 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverCapabilityInvokerFactoryTests.cs 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.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(); + } }