Merge branch 'fix/archreview-crit13-resilience-config-artifact'

This commit is contained in:
Joseph Doherty
2026-07-09 06:07:33 -04:00
10 changed files with 388 additions and 27 deletions
@@ -14,12 +14,20 @@ public interface IDriverCapabilityInvokerFactory
/// <summary> /// <summary>
/// Create the resilience invoker for one driver instance. The returned invoker is keyed on /// 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="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> /// </summary>
/// <param name="driverInstanceId">The driver instance identifier (pipeline + tracker key).</param> /// <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="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> /// <returns>A per-instance <see cref="IDriverCapabilityInvoker"/>.</returns>
IDriverCapabilityInvoker Create(string driverInstanceId, string driverType); IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null);
} }
/// <summary> /// <summary>
@@ -34,6 +42,6 @@ public sealed class NullDriverCapabilityInvokerFactory : IDriverCapabilityInvoke
private NullDriverCapabilityInvokerFactory() { } private NullDriverCapabilityInvokerFactory() { }
/// <inheritdoc /> /// <inheritdoc />
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType) => public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null) =>
NullDriverCapabilityInvoker.Instance; NullDriverCapabilityInvoker.Instance;
} }
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience; 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. /// resolves the abstraction from DI and never sees this type.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>Resilience policy is currently the <b>tier default</b> for the driver type — resolved /// <para>Resilience policy is the driver type's <b>tier default</b> (resolved via the injected
/// via the injected tier resolver (backed by <c>DriverFactoryRegistry.GetTier</c>). The /// tier resolver, backed by <c>DriverFactoryRegistry.GetTier</c>) with the per-instance
/// per-instance <c>DriverInstance.ResilienceConfig</c> JSON override is NOT yet applied because /// <c>DriverInstance.ResilienceConfig</c> JSON <b>layered on top</b> when the deployment artifact
/// the deployment artifact (<c>DriverInstanceSpec</c>) does not carry it; plumbing that column /// carries one (<c>DriverInstanceSpec.ResilienceConfig</c>, threaded in by the Runtime
/// through the composer/artifact is a tracked follow-up. Until then every instance of a given /// <c>DriverHostActor</c> at spawn). A missing/empty/malformed config degrades to pure tier
/// driver type gets that type's tier defaults.</para> /// 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), /// <para>Options are snapshotted once per <see cref="Create"/> (a driver actor's options are fixed
/// so the invoker's per-call options accessor is allocation-free on the hot path.</para> /// 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> /// </remarks>
public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory
{ {
private readonly DriverResiliencePipelineBuilder _builder; private readonly DriverResiliencePipelineBuilder _builder;
private readonly DriverResilienceStatusTracker _statusTracker; private readonly DriverResilienceStatusTracker _statusTracker;
private readonly Func<string, DriverTier> _tierResolver; private readonly Func<string, DriverTier> _tierResolver;
private readonly ILogger? _logger;
/// <summary>Construct the factory over the shared resilience infrastructure.</summary> /// <summary>Construct the factory over the shared resilience infrastructure.</summary>
/// <param name="builder">Process-singleton Polly pipeline builder (shared pipeline cache).</param> /// <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="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="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( public DriverCapabilityInvokerFactory(
DriverResiliencePipelineBuilder builder, DriverResiliencePipelineBuilder builder,
DriverResilienceStatusTracker statusTracker, DriverResilienceStatusTracker statusTracker,
Func<string, DriverTier> tierResolver) Func<string, DriverTier> tierResolver,
ILogger? logger = null)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(statusTracker); ArgumentNullException.ThrowIfNull(statusTracker);
@@ -40,14 +50,26 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
_builder = builder; _builder = builder;
_statusTracker = statusTracker; _statusTracker = statusTracker;
_tierResolver = tierResolver; _tierResolver = tierResolver;
_logger = logger;
} }
/// <inheritdoc /> /// <inheritdoc />
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType) public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null)
{ {
var tier = _tierResolver(driverType); var tier = _tierResolver(driverType);
// No per-instance ResilienceConfig in the artifact yet — tier defaults only (see remarks). var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceConfigJson, out var diagnostic);
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceConfigJson: null, out _); 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( return new CapabilityInvoker(
_builder, _builder,
driverInstanceId, driverInstanceId,
@@ -66,7 +66,8 @@ public static class DriverFactoryBootstrap
return new DriverCapabilityInvokerFactory( return new DriverCapabilityInvokerFactory(
sp.GetRequiredService<DriverResiliencePipelineBuilder>(), sp.GetRequiredService<DriverResiliencePipelineBuilder>(),
sp.GetRequiredService<DriverResilienceStatusTracker>(), sp.GetRequiredService<DriverResilienceStatusTracker>(),
registry.GetTier); registry.GetTier,
logger: sp.GetService<ILoggerFactory>()?.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 // Driver nodes also carry the probe set so a fused admin,driver node has it; the admin-only
@@ -18,7 +18,8 @@ public sealed record DriverInstanceSpec(
string DriverType, string DriverType,
bool Enabled, bool Enabled,
string DriverConfig, 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> /// <summary>How a node should scope a deployment artifact to its own ClusterId.</summary>
public enum ClusterFilterMode public enum ClusterFilterMode
@@ -167,6 +168,9 @@ public static class DeploymentArtifact
|| enEl.GetBoolean(); || enEl.GetBoolean();
var config = ReadString(el, "DriverConfig"); var config = ReadString(el, "DriverConfig");
var clusterId = ReadString(el, "ClusterId"); 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; if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
@@ -177,7 +181,8 @@ public static class DeploymentArtifact
DriverType: type!, DriverType: type!,
Enabled: enabled, Enabled: enabled,
DriverConfig: config ?? "{}", DriverConfig: config ?? "{}",
ClusterId: clusterId); ClusterId: clusterId,
ResilienceConfig: resilienceConfig);
} }
/// <summary> /// <summary>
@@ -244,6 +244,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
public string DriverType => Spec.DriverType; public string DriverType => Spec.DriverType;
/// <summary>Gets the last applied driver configuration JSON from the child's spec.</summary> /// <summary>Gets the last applied driver configuration JSON from the child's spec.</summary>
public string LastConfigJson => Spec.DriverConfig; 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> /// <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 specs = DeploymentArtifact.ParseDriverInstances(blob, _localNode.Value);
var snapshots = _children.ToDictionary( var snapshots = _children.ToDictionary(
kv => kv.Key, 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); StringComparer.Ordinal);
var plan = DriverSpawnPlanner.Compute(snapshots, specs); var plan = DriverSpawnPlanner.Compute(snapshots, specs);
@@ -1625,9 +1627,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
else else
{ {
// Attach the per-instance Phase 6.1 resilience invoker so this driver's capability calls // 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 // route through the retry/breaker/bulkhead/telemetry pipeline. The tier defaults are layered
// yields a no-op invoker on nodes without the real resilience factory bound. // with the per-instance ResilienceConfig from the artifact; the pass-through factory yields a
var invoker = _invokerFactory.Create(spec.DriverInstanceId, spec.DriverType); // no-op invoker on nodes without the real resilience factory bound.
var invoker = _invokerFactory.Create(spec.DriverInstanceId, spec.DriverType, spec.ResilienceConfig);
child = Context.ActorOf( child = Context.ActorOf(
DriverInstanceActor.Props( DriverInstanceActor.Props(
driver!, driver!,
@@ -42,8 +42,14 @@ public static class DriverSpawnPlanner
toStop.Add(id); toStop.Add(id);
continue; continue;
} }
// Driver type changes can't be reinitialized in-place (factory-bound) — stop + respawn. // A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn.
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal)) // 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); toStop.Add(id);
toSpawn.Add(spec); 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> /// <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);
@@ -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;
/// <summary>
/// Guards the per-instance ResilienceConfig plumbing (arch-review #13): the factory must layer the
/// per-instance <c>ResilienceConfig</c> JSON onto the tier defaults, invalidate any stale cached
/// pipelines for the instance on every <see cref="DriverCapabilityInvokerFactory.Create"/> (so a
/// respawn with a changed config takes effect despite the options-blind pipeline cache), and log —
/// never throw on — a malformed config.
/// </summary>
[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);
/// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>(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");
}
/// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>(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");
}
/// <summary>
/// Invalidate-on-change: because the pipeline cache is keyed on (instance, host, capability) and
/// ignores options on a hit, a fresh <see cref="DriverCapabilityInvokerFactory.Create"/> for an
/// instance must drop that instance's cached pipelines so a respawn with new options rebuilds them.
/// </summary>
[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");
}
/// <summary>
/// Invalidation is scoped to the instance being (re)created — a sibling instance's warm pipeline
/// must survive.
/// </summary>
[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");
}
/// <summary>A malformed ResilienceConfig logs a diagnostic but still yields a working (tier-default) invoker.</summary>
[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>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
}
}
@@ -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;
/// <summary>
/// Guards the production DI wiring for the Phase 6.1 resilience dispatch pipeline (arch-review #10 +
/// #13). <c>AddOtOpcUaDriverFactories</c> (the driver-node bootstrap) must bind the <b>real</b>
/// <see cref="DriverCapabilityInvokerFactory"/> — not the <see cref="NullDriverCapabilityInvokerFactory"/>
/// pass-through — and that factory must mint a real <see cref="CapabilityInvoker"/> 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.
/// </summary>
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<IDriverCapabilityInvokerFactory>();
factory.ShouldBeOfType<DriverCapabilityInvokerFactory>(
"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<IDriverCapabilityInvokerFactory>();
var invoker = factory.Create("drv-1", "Modbus");
invoker.ShouldBeOfType<CapabilityInvoker>(
"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<DriverResiliencePipelineBuilder>().ShouldNotBeNull();
sp.GetService<DriverResilienceStatusTracker>().ShouldNotBeNull();
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
}
}
@@ -650,4 +650,39 @@ public sealed class DeploymentArtifactTests
var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053"); var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E-c1" }); 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 public sealed class DriverSpawnPlannerTests
{ {
private static DriverInstanceSpec Spec(string id, string type = "Modbus", string config = "{\"host\":\"127.0.0.1\"}", bool enabled = true) => 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); 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> /// <summary>Verifies that all new drivers are placed in ToSpawn when current is empty.</summary>
[Fact] [Fact]
@@ -122,4 +122,66 @@ public sealed class DriverSpawnPlannerTests
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip"); plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
plan.ToApplyDelta.ShouldBeEmpty(); 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();
}
} }