test(otopcua): cover discovered-node rebind drop + clarify re-apply scope

This commit is contained in:
Joseph Doherty
2026-06-26 09:01:01 -04:00
parent 1aa13ebd27
commit 5104540e32
2 changed files with 60 additions and 5 deletions
@@ -1245,11 +1245,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_lastComposition = composition;
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, every redeploy /
// bootstrap-restore would drop the injected FixedTree routes + materialised nodes until the driver
// happens to reconnect and re-discover. Re-resolve each cached driver's equipment from the CURRENT
// composition; drop the cache entry if the driver/equipment no longer resolves to exactly one (a rebind
// or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from Task 6's
// post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's equipment from the
// CURRENT composition; drop the cache entry if the driver/equipment no longer resolves to exactly one
// (a rebind or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
{
var plan = _discoveredByDriver[driverId];
@@ -1267,6 +1270,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var equipmentId = equipmentIds[0];
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
// KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a
// CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's
// EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery —
// ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is
// never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment
// until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger
// logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the
// safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse).
var planEquipmentConsistent = plan.Variables.Count > 0
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
if (!planEquipmentConsistent)
@@ -341,6 +341,50 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the
/// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly
/// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD
/// equipment (EQ-1), so the <c>StartsWith(equipmentId + "/")</c> guard sees they no longer match EQ-2 and
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
[Fact]
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
{
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
Writable: false, IsHistorized: false),
}));
// First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1).
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
// Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still
// resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is
// unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the
// guard's known-limitation comment describes.
var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
(Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached
// EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/").
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the
/// deployment, and waits for the Applied ACK so the apply (and thus <c>_lastComposition</c> + the live
/// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A