Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs
T
Joseph Doherty d32d89c340 fix(drivers): stop silently discarding driver config edits (§8.3, #516)
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.

Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.

Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
  from each factory, called from InitializeAsync behind a HasConfigBody guard
  so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
  than options from config (Sql's dialect + resolved connection string,
  FOCAS's client-factory backend, both injected at construction), so adopting
  new options alone would run a NEW tag set against an OLD connection. Half a
  re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
  premise — "config parsing belongs to the factory, which builds a fresh
  instance" — which was false when written and is true only now.

Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.

Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.

Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.

Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
2026-07-27 19:32:46 -04:00

202 lines
8.1 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
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, 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]
public void All_new_drivers_go_into_ToSpawn_when_current_is_empty()
{
var current = new Dictionary<string, DriverChildSnapshot>();
var target = new[] { Spec("a"), Spec("b") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.Count.ShouldBe(2);
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
/// <summary>Verifies that the same configuration yields an empty plan.</summary>
[Fact]
public void Same_config_yields_empty_plan()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{\"host\":\"127.0.0.1\"}"),
};
var target = new[] { Spec("a") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
/// <summary>
/// A changed <c>DriverConfig</c> forces a stop + respawn (#516), NOT an in-place delta.
/// <para>This test previously asserted the opposite. The in-place path silently discarded the edit
/// on five drivers whose <c>InitializeAsync</c> served constructor-captured options and never read
/// the <c>driverConfigJson</c> they were handed — and the deployment still sealed green. Routing
/// through the factory makes it the single parse authority, which matters most for the drivers that
/// build MORE than options from config (Sql's dialect + connection string, FOCAS's client-factory
/// backend) where re-parsing options alone would apply a new tag set against an old connection.</para>
/// <para>The accepted cost is a reconnect on every config edit.</para>
/// </summary>
[Fact]
public void Different_config_routes_to_stop_and_respawn()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{\"host\":\"old\"}"),
};
var target = new[] { Spec("a", config: "{\"host\":\"new\"}") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}");
// The whole point: nothing is left on the in-place path that could discard the edit.
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>Verifies that removed drivers are routed to ToStop.</summary>
[Fact]
public void Removed_driver_routes_to_ToStop()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{\"host\":\"127.0.0.1\"}"),
["b"] = new("Modbus", "{}"),
};
var target = new[] { Spec("a") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToStop.ShouldBe(new[] { "b" });
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>Verifies that disabled drivers with running children are routed to ToStop.</summary>
[Fact]
public void Disabled_driver_with_running_child_routes_to_ToStop()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{}"),
};
var target = new[] { Spec("a", enabled: false) };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>Verifies that disabled new drivers are not spawned.</summary>
[Fact]
public void Disabled_new_driver_is_not_spawned()
{
var current = new Dictionary<string, DriverChildSnapshot>();
var target = new[] { Spec("a", enabled: false) };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
/// <summary>Verifies that driver type changes trigger stop followed by respawn.</summary>
[Fact]
public void Driver_type_change_triggers_stop_plus_respawn()
{
var current = new Dictionary<string, DriverChildSnapshot>
{
["a"] = new("Modbus", "{}"),
};
var target = new[] { Spec("a", type: "AbCip") };
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToStop.Single().ShouldBe("a");
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 respawns even when the ResilienceConfig is UNCHANGED.
/// <para>This asserted the opposite until #516. The reasoning then was "the resilience pipeline is
/// untouched, so there's no reason to respawn" — correct about resilience, wrong about the driver:
/// five drivers ignored the config the in-place delta handed them, and the deployment sealed green
/// anyway. The reconnect is the accepted price of the edit actually taking effect.</para>
/// </summary>
[Fact]
public void DriverConfig_change_respawns_even_when_ResilienceConfig_is_unchanged()
{
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.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
}
}