From 4b9bcddbcc01da9dc580aafd81a8f19c8c4cabb7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Jul 2026 10:30:19 -0400 Subject: [PATCH] feat(drivers): periodic reconcile of desired vs actual subscriptions (#488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option A from the issue — the cheap consistency check, no interface change. The desired ref set was re-applied on a Connected TRANSITION (`ResubscribeDesired`) and on a deploy's `SetDesiredSubscriptions`, and nowhere else. A subscription lost while the driver STAYED Connected — a subscribe that failed and was never retried, a handle dropped down an error path — left the actor subscribed to nothing while still reporting Healthy, indefinitely. `ReconcileSubscription` now rides the existing 30 s `health-poll` tick: while Connected, a driver holding a desired set but no live handle is re-subscribed. Gated on `ISubscribable`. A driver that cannot subscribe at all can still be handed a desired set, and without the guard it would be told `Subscribe` every tick and fail every tick, forever. This is a state-machine consistency check, NOT a probe — it can only see that this actor holds no handle, never that a handle is present but dead server-side, because `ISubscriptionHandle` is opaque. That is option B, and it stays deferred until a real occurrence shows the handle outliving the subscription; it would need a liveness member implemented across all eight drivers with different subscription mechanics. A redundant `Subscribe` is possible (a tick queued ahead of an already-queued one observes the same no-handle state) and is harmless: `HandleSubscribeAsync` is a `ReceiveAsync`, so no tick is processed mid-subscribe, and it drops any prior subscription before establishing the new one. Suppressing it would need a pending-subscribe flag threaded through every self-tell site — more state than the race is worth. `healthPollInterval` becomes an optional Props parameter, mirroring the existing `rediscoverInterval` seam, so a test can drive the reconcile without waiting 30 s. Three tests. The reconcile itself is proven by a stub whose FIRST subscribe throws: nothing else would ever retry it, so a second attempt can only come from the reconcile — disabling `ReconcileSubscription` turns it red. The two guard tests are absence assertions and are bounded by a positive control rather than a sleep: a counting health publisher makes ticks PROCESSED observable, so "still one subscribe" means the reconcile ran and declined, not that the timer had not fired. Explicitly not deploy-time drift (#486) — re-asserting an already-correct desired set would not have helped there. --- .../Drivers/DriverInstanceActor.cs | 71 +++++++++- ...InstanceActorSubscriptionReconcileTests.cs | 122 ++++++++++++++++++ .../Drivers/StubDrivers.cs | 19 ++- 3 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs 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 3373c182..c0ca7f97 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -170,6 +170,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Interval between bounded post-connect re-discovery passes. Production default 2s; tests /// inject a tiny value so the loop runs without real-time waits. private readonly TimeSpan _rediscoverInterval; + private readonly TimeSpan _healthPollInterval; /// 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. @@ -239,6 +240,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Optional per-pass timeout for ; defaults to 30 seconds. /// Optional Phase 6.1 resilience invoker wrapping this driver's capability /// calls; defaults to (pass-through) when not supplied. + /// Optional period of the health-poll heartbeat, which also drives the + /// subscription reconcile (); defaults to + /// . Exists so a test can drive the reconcile without waiting 30 s. /// A instance configured to create the wrapped . public static Props Props( IDriver driver, @@ -249,7 +253,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers TimeSpan? rediscoverInterval = null, int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, TimeSpan? rediscoverDiscoverTimeout = null, - IDriverCapabilityInvoker? invoker = null) => + IDriverCapabilityInvoker? invoker = null, + TimeSpan? healthPollInterval = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( driver, reconnectInterval ?? DefaultReconnectInterval, @@ -259,7 +264,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers rediscoverInterval, rediscoverMaxAttempts, rediscoverDiscoverTimeout, - invoker)); + invoker, + healthPollInterval)); /// /// Returns true when the driver should boot in DEV-STUB mode based on host platform and @@ -293,6 +299,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Per-pass timeout for ; defaults to 30 seconds. /// Phase 6.1 resilience invoker wrapping this driver's capability calls; /// defaults to (pass-through) when null. + /// Period of the health-poll heartbeat, which also drives the + /// subscription reconcile; defaults to when null. public DriverInstanceActor( IDriver driver, TimeSpan reconnectInterval, @@ -302,7 +310,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers TimeSpan? rediscoverInterval = null, int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, TimeSpan? rediscoverDiscoverTimeout = null, - IDriverCapabilityInvoker? invoker = null) + IDriverCapabilityInvoker? invoker = null, + TimeSpan? healthPollInterval = null) { _driver = driver; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance; @@ -313,6 +322,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; _rediscoverMaxAttempts = rediscoverMaxAttempts; _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; + _healthPollInterval = healthPollInterval ?? HealthPollInterval; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"), new KeyValuePair("driver_type", driver.DriverType)); @@ -335,7 +345,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // actor starts, before any state transition fires. Also start the periodic heartbeat so // long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients. PublishHealthSnapshot(); - Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, HealthPollInterval); + Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval); } private void Stubbed() @@ -489,7 +499,58 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter. Receive(msg => _log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason)); - Receive(_ => PublishHealthSnapshot()); + Receive(_ => + { + PublishHealthSnapshot(); + ReconcileSubscription(); + }); + } + + /// + /// Periodic desired-vs-actual subscription reconcile (#488), riding the existing health-poll + /// tick. While Connected, a driver that has a desired subscription set but no live handle is + /// re-subscribed. + /// + /// + /// + /// The desired set is otherwise only (re)applied on a Connected transition + /// () or on a deploy's . + /// Nothing re-asserted it for a subscription that was lost while the driver stayed + /// Connected — a failed subscribe whose retry never came, or a handle dropped down an error + /// path. That is the hole this closes. + /// + /// + /// This is a state-machine consistency check, not a probe. It can only see that this + /// actor holds no handle; it cannot detect a handle that is present but dead server-side, + /// because ISubscriptionHandle is opaque (a DiagnosticId string and nothing + /// else). Detecting that needs a liveness member on ISubscribable implemented across + /// all eight drivers, each with different subscription mechanics — deliberately deferred + /// until a real occurrence shows the handle outliving the subscription. + /// + /// + /// Gated on : a driver that cannot subscribe at all may still have + /// been handed a desired set, and re-telling Subscribe to it every tick would fail + /// every tick, forever. + /// + /// + /// A redundant Subscribe is possible but harmless: a tick sitting in the mailbox ahead + /// of an already-queued Subscribe observes the same "no handle" state and tells a + /// second one. is a ReceiveAsync (so no tick can be + /// processed mid-subscribe) and drops any prior subscription before establishing the new one, + /// so the duplicate costs one extra round-trip and converges. Suppressing it would need a + /// pending-subscribe flag threaded through every self-tell site — more state than the race + /// is worth. + /// + /// + private void ReconcileSubscription() + { + if (_driver is not ISubscribable) return; + if (_desiredRefs.Count == 0 || _subscriptionHandle is not null) return; + + _log.Info( + "DriverInstance {Id}: connected with {Count} desired subscription ref(s) but no live subscription handle — re-subscribing (periodic reconcile)", + _driverInstanceId, _desiredRefs.Count); + Self.Tell(new Subscribe(_desiredRefs, _desiredInterval)); } private void Reconnecting() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs new file mode 100644 index 00000000..1a901650 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs @@ -0,0 +1,122 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Gitea #488 — the periodic desired-vs-actual subscription reconcile that rides +/// 's existing health-poll tick. +/// +/// +/// +/// The hole it closes: the desired ref set is re-applied on a Connected transition and on a +/// deploy's SetDesiredSubscriptions, and nowhere else. A subscription lost while the driver +/// stayed Connected — the shape reproduced here, a subscribe that failed and was never +/// retried — left the actor permanently subscribed to nothing while reporting Healthy. +/// +/// +/// All three tests drive a short healthPollInterval rather than the production 30 s. The +/// two absence assertions are bounded by a positive control (a counted number of ticks provably +/// processed), never by a bare sleep. +/// +/// +public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActorTestBase +{ + private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(100); + + /// + /// The reconcile itself: the first subscribe fails, leaving the actor Connected with a desired set + /// and no handle. Nothing else would ever retry it — no reconnect, no deploy — so a second + /// subscribe can only come from the periodic reconcile. + /// + [Fact] + public void A_lost_subscription_is_re_established_while_still_connected() + { + var driver = new SubscribableStubDriver { SubscribeFailuresBeforeSuccess = 1 }; + var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, healthPollInterval: FastPoll)); + + actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( + new[] { "tag-a" }, TimeSpan.FromMilliseconds(100))); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + // The connect-path subscribe runs and throws, so the handle stays null. + AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget); + + // Only the reconcile can produce this second attempt — and it must succeed this time. + AwaitCondition(() => driver.SubscribeCount >= 2, PresenceBudget); + driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a" }); + } + + /// + /// A healthy subscription is left alone — the reconcile must not re-subscribe on every tick, which + /// would churn the backend every 30 s in production for no reason. + /// + [Fact] + public void A_healthy_subscription_is_not_re_subscribed_on_every_tick() + { + var publisher = new CountingHealthPublisher(); + var driver = new SubscribableStubDriver(); + var actor = Sys.ActorOf(DriverInstanceActor.Props( + driver, healthPublisher: publisher, healthPollInterval: FastPoll)); + + actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( + new[] { "tag-a" }, TimeSpan.FromMilliseconds(100))); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget); + + // POSITIVE CONTROL for the absence below. Every health-poll tick publishes a snapshot, so the + // publisher count is a direct observation of ticks PROCESSED. Waiting for several of them is what + // makes "SubscribeCount is still 1" mean "the reconcile ran and declined" rather than "the timer + // had not fired yet" — the latter would pass instantly and prove nothing. + var before = publisher.Count; + AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget); + + driver.SubscribeCount.ShouldBe(1, + "the handle is live, so the reconcile must leave it alone"); + } + + /// + /// A driver that is not can still be handed a desired set. Reconciling + /// it would tell Subscribe every tick and fail every tick, forever — so the reconcile is + /// gated on the capability. + /// + [Fact] + public void A_non_subscribable_driver_is_never_reconciled() + { + var publisher = new CountingHealthPublisher(); + var driver = new StubDriver(); // IDriver only — no ISubscribable + var actor = Sys.ActorOf(DriverInstanceActor.Props( + driver, healthPublisher: publisher, healthPollInterval: FastPoll)); + + actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( + new[] { "tag-a" }, TimeSpan.FromMilliseconds(100))); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + // Same positive control: assert the reconcile stays silent across several PROCESSED ticks. + // EventFilter bounds the absence to this block, and the reconcile announces itself at Info — + // the first test above is the proof that this message does appear when the reconcile fires. + EventFilter.Info(contains: "periodic reconcile").Expect(0, () => + { + var before = publisher.Count; + AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget); + }); + } + + /// Counts health-snapshot publishes, which is one per health-poll tick once the actor + /// is running — the observable that turns "time passed" into "ticks were processed". + private sealed class CountingHealthPublisher : IDriverHealthPublisher + { + private int _count; + + /// Number of snapshots published so far. + public int Count => Volatile.Read(ref _count); + + /// + public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min) + => Interlocked.Increment(ref _count); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs index 9e83fb46..2ab4213d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs @@ -114,6 +114,18 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable /// synchronously-completed stub task hides (the continuation otherwise runs inline). public bool UnsubscribeYields { get; set; } + /// + /// How many of the first calls throw before one succeeds. Default 0 + /// (always succeed) leaves existing behaviour untouched. + /// + /// + /// This is how a test reaches the state the #488 reconcile exists for: the actor is Connected and + /// holds a desired ref set, but the subscribe that should have established the handle failed, so + /// _subscriptionHandle is null and nothing else will ever re-assert it — there is no + /// connect transition coming, and no deploy. + /// + public int SubscribeFailuresBeforeSuccess { get; set; } + /// Subscribes to the specified full references. /// The full references to subscribe to. /// The publishing interval. @@ -121,8 +133,13 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) { - Interlocked.Increment(ref SubscribeCount); + var attempt = Interlocked.Increment(ref SubscribeCount); LastSubscribedRefs = fullReferences; + if (attempt <= SubscribeFailuresBeforeSuccess) + { + throw new InvalidOperationException($"stub subscribe failure #{attempt}"); + } + return Task.FromResult(_handle); }