From 5104540e327984a0975eaf39114bc3794f16118a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 09:01:01 -0400 Subject: [PATCH] test(otopcua): cover discovered-node rebind drop + clarify re-apply scope --- .../Drivers/DriverHostActor.cs | 21 ++++++--- .../Drivers/DriverHostActorDiscoveryTests.cs | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) 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 5415a1de..0d4a573e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -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) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs index f08d3579..54706648 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -341,6 +341,50 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// 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 StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and + /// drops the entry. After the redeploy NO is + /// re-told. (Complements , which + /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) + [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(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(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(Timeout); + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + /// 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 _lastComposition + the live /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A