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 23225b7c..5415a1de 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -1243,6 +1243,40 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore // paths — which both route through this method — leave a current composition. _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). + foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below + { + var plan = _discoveredByDriver[driverId]; + var equipmentIds = composition.EquipmentTags + .Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) + .Select(t => t.EquipmentId) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (equipmentIds.Count != 1) + { + _discoveredByDriver.Remove(driverId); + _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver} — equipment no longer resolves uniquely", _localNode, driverId); + continue; + } + 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}/...". + var planEquipmentConsistent = plan.Variables.Count > 0 + && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal); + if (!planEquipmentConsistent) + { + _discoveredByDriver.Remove(driverId); + _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver} — equipment rebound", _localNode, driverId); + continue; + } + ApplyDiscoveredPlan(driverId, equipmentId, plan); + } } private void SpawnChild(DriverInstanceSpec spec) 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 5d5eb124..f08d3579 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 @@ -44,6 +44,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); + private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); @@ -63,7 +64,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - var (actor, publish) = SpawnHostAndApply(db, deploymentId, factory); + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the // mapper keeps it (it does not shadow an authored ref). @@ -155,7 +156,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - var (actor, publish) = SpawnHostAndApply(db, deploymentId, factory); + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new. actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] @@ -190,7 +191,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - var (actor, publish) = SpawnHostAndApply(db, deploymentId, factory); + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); var node1 = new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, @@ -232,13 +233,121 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase }, duration: Timeout); } + /// Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a + /// redeploy. A second deployment re-runs PushDesiredSubscriptions, which clears the live-value + /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected + /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached + /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's + /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. + [Fact] + public void Discovered_nodes_survive_a_redeploy_rebuild() + { + 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); + + var discovered = 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), + }; + actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); + + // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts. + var materialise1 = publish.ExpectMsg(Timeout); + materialise1.EquipmentRootNodeId.ShouldBe("EQ-1"); + materialise1.Variables.Count.ShouldBe(1); + var fixedTreeNodeId = materialise1.Variables[0].NodeId; + + // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't + // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds + // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals. + var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, + (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); + actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); + coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + // The redeploy fires a fresh RebuildAddressSpace first (drain it) ... + publish.ExpectMsg(Timeout); + + // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply), + // at the SAME NodeId the first injection placed it. + var materialise2 = publish.ExpectMsg(Timeout); + materialise2.EquipmentRootNodeId.ShouldBe("EQ-1"); + materialise2.Variables.Count.ShouldBe(1); + materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId); + + // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto + // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear(). + AwaitAssert(() => + { + var refs = factory.LastSubscribedRefs; + refs.ShouldNotBeNull(); + refs!.ShouldContain("40001"); + refs.ShouldContain("ft-ref-1"); + }, duration: Timeout); + + // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the + // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()). + actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); + var update = publish.ExpectMsg(Timeout); + update.NodeId.ShouldBe(fixedTreeNodeId); + update.Value.ShouldBe(42.0); + update.Quality.ShouldBe(OpcUaQuality.Good); + } + + /// Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment + /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a + /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell + /// for the now-unresolved driver. + [Fact] + public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() + { + 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. + publish.ExpectMsg(Timeout); + + // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for + // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped. + var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, + (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2")); + actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); + coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is + // re-told (the cached d1 plan was dropped because its equipment no longer resolves). + 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 /// VirtualTag-host probe is injected so the real host isn't spawned. The /// that lands on the publish probe during apply is drained so the test's materialise / value-update /// assertions see only post-apply traffic. - private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply( + private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) { var coordinator = CreateTestProbe(); @@ -257,7 +366,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase publish.ExpectMsg(Timeout); - return (actor, publish); + return (actor, publish, coordinator); } ///