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 acb1e5a4..34460595 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -770,6 +770,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses // that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all // shadowed by authored refs) contributes no entry. + // NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW + // StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device + // emits one consistent HostAddress string per device, so a partition is single-host either way (collapse + // fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be + // a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope + // already isolates them). var plans = new Dictionary(StringComparer.Ordinal); foreach (var (host, nodes) in matchedNodes) { @@ -1534,6 +1540,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (plansByEquipment.Count == 0) { _discoveredByDriver.Remove(driverId); + // Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't + // leak a stale entry (log-level-only state; bounded by driver count — just tidiness). + _lastPartitionWarnSignature.Remove(driverId); // FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its // plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would // otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only 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 6a9e3ff7..9cc2f0d8 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 @@ -233,10 +233,15 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run"); var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId; var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId; - // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries no host. - eqANodeId.ShouldStartWith("EQ-A/"); + // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host + // segment and reads EQ-n/FOCAS//. Assert the EXACT collapsed path (depth + leaf) so a + // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) — + // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY + // (a lowercase-only "h1" check would miss a leaked raw "H1"). + eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model")); + eqANodeId.ShouldNotContain("H1"); eqANodeId.ShouldNotContain("h1"); - eqBNodeId.ShouldStartWith("EQ-B/"); + eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run")); eqBNodeId.ShouldNotContain("h2"); // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). @@ -294,6 +299,50 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } + /// Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE, + /// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the + /// same set) are quiet — proving ShouldWarnPartition's per-driver signature dedup. The repeat is + /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter + /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on + /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.) + [Fact] + public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() + { + var db = NewInMemoryDbFactory(); + var factory = new SubscribingDriverFactory("Modbus"); + var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", + (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), + (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); + + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); + + var discovered = new[] + { + new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, + BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, + BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, + BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), + }; + + // Pass 1: the unmatched "h3" partition warns EXACTLY once. + EventFilter.Warning(contains: "discovered device-host partition(s) skipped") + .Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); + + // Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous. + publish.ExpectMsg(Timeout); + publish.ExpectMsg(Timeout); + + // Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the + // suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings. + EventFilter.Warning(contains: "discovered device-host partition(s) skipped") + .Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); + } + /// Guard: a arriving BEFORE any deployment /// is applied (_lastComposition still null) is ignored — nothing is materialised on the publish /// side (the equipment can't be resolved without a composition).