From f7358bf4fd311d671ecc953b506155f2b8ac4ef5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 12:45:26 -0400 Subject: [PATCH] feat(otopcua): DriverInstanceActor.TriggerRediscovery message (follow-up C) --- .../Drivers/DriverInstanceActor.cs | 22 +++++ .../DriverInstanceActorDiscoveryTests.cs | 87 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 1b134937..b257c82d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -113,6 +113,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// the parent dedups and injection is idempotent. public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList Nodes); + /// + /// Sent by to ask this driver child to re-run post-connect discovery + /// after the host rebinds the driver to a new equipment. Handled only in Connected, where it + /// re-kicks — which already honours the driver's + /// and the guard, tagging the + /// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op: + /// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log. + /// + public sealed record TriggerRediscovery; + /// Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect). /// is the ordered-distinct full-reference signature of the prior pass's /// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it. @@ -312,6 +322,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a // re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message. Receive(_ => { }); + // A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it. + Receive(_ => { }); Receive(_ => PublishHealthSnapshot()); } @@ -368,6 +380,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. Receive(_ => { }); + // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path + // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). + Receive(_ => { }); Receive(_ => PublishHealthSnapshot()); } @@ -393,6 +408,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers PublishHealthSnapshot(); }); ReceiveAsync(HandleRediscoverAsync); + // The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Re-kick the + // bounded loop via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the + // current _initGeneration). Only handled here in Connected — non-Connected states no-op it below. + Receive(_ => StartDiscovery()); ReceiveAsync(HandleWriteAsync); ReceiveAsync(HandleAcknowledgeAsync); ReceiveAsync(HandleSubscribeAsync); @@ -476,6 +495,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. Receive(_ => { }); + // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path + // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). + Receive(_ => { }); Receive(_ => PublishHealthSnapshot()); Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs index 11784ca0..e106bc92 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs @@ -292,6 +292,93 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase parent.ExpectMsg(TimeSpan.FromSeconds(2)); } + /// + /// received while Connected re-kicks the + /// post-connect discovery loop: after the initial discovery has settled, sending the message drives a + /// FRESH discovery pass — the driver's DiscoverCount advances and a new + /// is published. This is the message + /// uses to re-run discovery after rebinding the driver to a new equipment. + /// + [Fact] + public void TriggerRediscovery_when_Connected_reruns_discovery() + { + var driver = new DiscoverableStubDriver(); + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + // Let the initial post-connect loop settle (passes 0,0,3,3) and confirm it stopped. + for (var i = 0; i < 4; i++) + parent.ExpectMsg(TimeSpan.FromSeconds(2)); + parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + var passesBeforeTrigger = driver.DiscoverCount; // 4 + + // Re-kick discovery via the new message — the cache is warm, so the fresh pass sees the 3-node set. + actor.Tell(new DriverInstanceActor.TriggerRediscovery()); + + var afterTrigger = parent.ExpectMsg(TimeSpan.FromSeconds(2)); + afterTrigger.Nodes.Count.ShouldBe(3); + afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId); + + // A fresh pass genuinely ran — DiscoverCount advanced past the settled count. + driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeTrigger); + } + + /// + /// on a driver whose + /// is does + /// NOT re-discover: the handler calls StartDiscovery, which returns early for Never, so + /// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out. + /// + [Fact] + public void TriggerRediscovery_with_policy_Never_does_not_rediscover() + { + var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); + + // Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return. + actor.Tell(new DriverInstanceActor.TriggerRediscovery()); + + parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + driver.DiscoverCount.ShouldBe(0); + } + + /// + /// received while NOT Connected (still Connecting, + /// before init completes) is a clean silent no-op: no discovery pass runs, nothing is published, and + /// the actor neither crashes nor dies (the driver's eventual reconnect re-discovers anyway). A + /// follow-up connect then discovers normally, proving the actor is unharmed. + /// + [Fact] + public void TriggerRediscovery_when_not_Connected_is_a_silent_noop() + { + var driver = new DiscoverableStubDriver(); + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); + Watch(actor); + + // The actor boots into Connecting; send the trigger BEFORE InitializeRequested so it is handled + // in a non-Connected state. + actor.Tell(new DriverInstanceActor.TriggerRediscovery()); + + // No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor). + parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + driver.DiscoverCount.ShouldBe(0); + + // Sanity: the actor still works — driving it to Connected discovers normally afterwards. + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + parent.ExpectMsg(TimeSpan.FromSeconds(2)); + } + /// /// A that also exposes . Each DiscoverAsync /// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set —