feat(otopcua): make FixedTree re-discovery per-pass timeout injectable (follow-up A)

This commit is contained in:
Joseph Doherty
2026-06-26 12:12:47 -04:00
parent 37cac5dee5
commit c2c368dcec
2 changed files with 45 additions and 4 deletions
@@ -38,6 +38,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>Default cap on the number of post-connect re-discovery passes.</summary> /// <summary>Default cap on the number of post-connect re-discovery passes.</summary>
public const int DefaultRediscoverMaxAttempts = 15; public const int DefaultRediscoverMaxAttempts = 15;
/// <summary>Default per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during
/// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s.</summary>
public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);
public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation); public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation); public sealed record InitializeFailed(string Reason, int Generation);
@@ -135,6 +139,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising /// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary> /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
private readonly int _rediscoverMaxAttempts; private readonly int _rediscoverMaxAttempts;
/// <summary>Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during bounded post-connect
/// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter
/// value. Stored to allow injection rather than hardcoding.</summary>
private readonly TimeSpan _rediscoverDiscoverTimeout;
private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson; private string? _currentConfigJson;
@@ -192,6 +201,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// defaults to an empty string when not provided (e.g. in unit tests).</param> /// defaults to an empty string when not provided (e.g. in unit tests).</param>
/// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param> /// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
/// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param> /// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param>
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
public static Props Props( public static Props Props(
IDriver driver, IDriver driver,
TimeSpan? reconnectInterval = null, TimeSpan? reconnectInterval = null,
@@ -199,7 +209,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null, IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null, string? clusterId = null,
TimeSpan? rediscoverInterval = null, TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts) => int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor( Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver, driver,
reconnectInterval ?? DefaultReconnectInterval, reconnectInterval ?? DefaultReconnectInterval,
@@ -207,7 +218,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
healthPublisher ?? NullDriverHealthPublisher.Instance, healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty, clusterId ?? string.Empty,
rediscoverInterval, rediscoverInterval,
rediscoverMaxAttempts)); rediscoverMaxAttempts,
rediscoverDiscoverTimeout));
/// <summary> /// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and /// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -241,6 +253,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param> /// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
/// <param name="rediscoverInterval">Interval between post-connect re-discovery passes; defaults to 2 seconds.</param> /// <param name="rediscoverInterval">Interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
/// <param name="rediscoverMaxAttempts">Cap on the number of re-discovery passes; defaults to 15.</param> /// <param name="rediscoverMaxAttempts">Cap on the number of re-discovery passes; defaults to 15.</param>
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
public DriverInstanceActor( public DriverInstanceActor(
IDriver driver, IDriver driver,
TimeSpan reconnectInterval, TimeSpan reconnectInterval,
@@ -248,7 +261,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null, IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null, string? clusterId = null,
TimeSpan? rediscoverInterval = null, TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts) int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null)
{ {
_driver = driver; _driver = driver;
_driverInstanceId = driver.DriverInstanceId; _driverInstanceId = driver.DriverInstanceId;
@@ -257,6 +271,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_reconnectInterval = reconnectInterval; _reconnectInterval = reconnectInterval;
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
_rediscoverMaxAttempts = rediscoverMaxAttempts; _rediscoverMaxAttempts = rediscoverMaxAttempts;
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"), new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
new KeyValuePair<string, object?>("driver_type", driver.DriverType)); new KeyValuePair<string, object?>("driver_type", driver.DriverType));
@@ -762,7 +777,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
var builder = new CapturingAddressSpaceBuilder(); var builder = new CapturingAddressSpaceBuilder();
// Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded // Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded
// DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it. // DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout);
// NO ConfigureAwait(false): a genuinely-async DiscoverAsync (Galaxy / OpcUaClient / TwinCAT) must // NO ConfigureAwait(false): a genuinely-async DiscoverAsync (Galaxy / OpcUaClient / TwinCAT) must
// resume on the actor task scheduler so the Context.Parent.Tell + Timers calls below run with a // resume on the actor task scheduler so the Context.Parent.Tell + Timers calls below run with a
// live ActorContext. ConfigureAwait(false) would resume off-context and throw // live ActorContext. ConfigureAwait(false) would resume off-context and throw
@@ -171,6 +171,32 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
driver.DiscoverCount.ShouldBe(3); driver.DiscoverCount.ShouldBe(3);
} }
/// <summary>
/// The per-pass discovery timeout is injectable via <see cref="DriverInstanceActor.Props"/> so tests
/// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving).
/// Wiring is verified by constructing via <c>Props</c> with a custom value and confirming the actor starts
/// and begins discovery normally.
/// </summary>
[Fact]
public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value()
{
// The constant must exist and preserve the pre-refactor 30 s literal.
DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30));
// Props must accept the new optional parameter — no throw and actor starts normally.
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
rediscoverInterval: TimeSpan.FromMilliseconds(20),
rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Actor starts and discovery publishes — confirms the custom timeout was wired without error.
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
}
/// <summary> /// <summary>
/// A <see cref="StubDriver"/> that also exposes <see cref="ITagDiscovery"/>. Each <c>DiscoverAsync</c> /// A <see cref="StubDriver"/> that also exposes <see cref="ITagDiscovery"/>. Each <c>DiscoverAsync</c>
/// pass is counted; passes 12 yield nothing (cache warming), passes 3+ yield a stable 3-node set — /// pass is counted; passes 12 yield nothing (cache warming), passes 3+ yield a stable 3-node set —