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);
}
///