diff --git a/CLAUDE.md b/CLAUDE.md index 3691fd00..70fd354b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1162,6 +1162,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw "tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`. +- **Data and alarm subscriptions are independent in `DriverInstanceActor` (#662):** a data re-subscribe (every + deploy's bulk push to a kept driver) detaches DATA only (`DetachDataSubscription`) — it used to unhook + `OnAlarmEvent` too, silently killing the native-alarm feed until a respawn. The health-poll reconcile + re-attaches/re-subscribes alarms as well as data, so a failed alarm subscribe is retried every tick. + `docs/DriverLifecycle.md`. - **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact. Optional `localTsap`/`remoteTsap`/`connectionType` override the slot TSAP (`remoteTsap` ⊕ `connectionType`). Test diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index f1ea741a..0838cd34 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -242,6 +242,19 @@ Things to know before touching any of this: re-subscribing** so a filtering driver does not accumulate two overlapping ones. There is still deliberately no unsubscribe-on-**empty**: a removed alarm tag's condition node is torn down on the address-space rebuild and `ForwardNativeAlarm` drops any transition whose `ConditionId` no longer maps. +- ⚠️ **A data re-subscribe must not touch the alarm feed (#662).** Every deploy's bulk push re-sends + `SetDesiredSubscriptions` to every kept driver, and `HandleSubscribeAsync` drops the prior DATA + subscription first. That teardown used to be the full `DetachSubscription`, which also unhooked + `OnAlarmEvent` and forgot the alarm handle — so on any deploy that did **not** respawn a driver (e.g. one + that only disabled another driver) its alarm subscription kept logging "established" while every event + reached nobody, until a reconnect or respawn. The data path now uses `DetachDataSubscription`; only a lost + connection (`DisconnectObserved` / `ForceReconnect`) and `PostStop` detach both. The **#488 periodic + reconcile covers alarms too** (`ReconcileAlarmSubscription`): it re-attaches a missing `OnAlarmEvent` + handler and re-subscribes when the live alarm handle does not match the desired set, so an alarm subscribe + that failed on (re)connect — a session not yet ready after an outage — is retried every health-poll tick + instead of gating the feed for the life of the connection. OpcUaClient removes a half-built alarm + subscription from its session when the subscribe throws, so those retries do not accumulate. + End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`. - **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision` asserts only that a child exists after a restart, and it passed green against the dead driver 03/S16 describes. It is kept — its own subject, the `InvalidActorNameException`, is real — but the load-bearing diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 53144baf..e851bc9c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -1701,6 +1701,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit AttributeId = Attributes.Value, }); + // Set by CreateAlarmEventItemAsync so a failure after the handler is attached can detach it (#662). + MonitoredItem? eventItem = null; + MonitoredItemNotificationEventHandler? notifHandler = null; + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -1710,9 +1714,44 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit ?? throw new InvalidOperationException( "OpcUaClient session was lost before the alarm subscription could be created."); session.AddSubscription(subscription); + try + { + await CreateAlarmEventItemAsync().ConfigureAwait(false); + } + catch + { + // #662: the host retries a failed alarm subscribe from its reconcile, so a half-built subscription + // left on the session would accumulate one per retry for the length of an outage. Detach the + // handler first — the remove is best-effort and can itself fail against a broken server — and + // bound it, since _gate is held. OnEventNotification also drops anything from an unregistered id. + if (eventItem is not null && notifHandler is not null) + { + try { eventItem.Notification -= notifHandler; } + catch { /* best-effort */ } + } + try + { + using var removeCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await session.RemoveSubscriptionAsync(subscription, removeCts.Token).ConfigureAwait(false); + } + catch { /* best-effort — the session may be the thing that failed */ } + throw; + } + + // #619: a new event item only reports FUTURE transitions — a condition already active upstream would + // stay invisible until it next changed. ConditionRefresh re-delivers every retained condition's + // current state through the normal path (deduplicated against the last-told state). + await TryConditionRefreshAsync(subscription, cancellationToken).ConfigureAwait(false); + } + finally { _gate.Release(); } + + return handle; + + async Task CreateAlarmEventItemAsync() + { await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); - var eventItem = new MonitoredItem(telemetry: null!, new MonitoredItemOptions + eventItem = new MonitoredItem(telemetry: null!, new MonitoredItemOptions { DisplayName = "Server/Events", StartNodeId = ObjectIds.Server, @@ -1729,22 +1768,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // can `-=` it later. The lambda captures `handle` and `sourceFilter`, so without // the explicit detach the SDK's invocation list keeps the driver instance alive // until the session itself is disposed. - MonitoredItemNotificationEventHandler notifHandler = (mi, args) => + notifHandler = (mi, args) => OnEventNotification(handle, sourceFilter, mi, args); eventItem.Notification += notifHandler; subscription.AddItem(eventItem); await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false); _alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle, eventItem, notifHandler); - - // #619: a new event item only reports FUTURE transitions — a condition already active upstream would - // stay invisible until it next changed. ConditionRefresh re-delivers every retained condition's - // current state through the normal path (deduplicated against the last-told state). - await TryConditionRefreshAsync(subscription, cancellationToken).ConfigureAwait(false); } - finally { _gate.Release(); } - - return handle; } /// @@ -1882,6 +1913,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit MonitoredItemNotificationEventArgs args) { if (args.NotificationValue is not EventFieldList efl) return; + // #662 review: only a subscription SubscribeAlarmsAsync completed (and nothing has since released) may + // deliver. A half-built one whose cleanup failed against a broken server can survive on the session and + // be transferred by an SDK reconnect; without this it would feed RouteAlarmEvent alongside the real one. + if (!_alarmSubscriptions.ContainsKey(handle.Id)) return; RouteAlarmEvent(efl.EventFields, sourceFilter, handle); } 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 ceeaab39..4c8912cf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -281,6 +281,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// newly-added alarm for the life of the connection. Empty whenever no handle is held. private IReadOnlyList _subscribedAlarmRefs = Array.Empty(); + /// #662 — consecutive failures; reset on success and on + /// (a new connection starts a new streak). + private int _alarmSubscribeFailures; + + /// #662 — health-poll ticks the alarm reconcile still skips before its next retry (backoff). + private int _alarmRetrySkipTicks; + + /// #662 — backoff cap: at the production 30 s tick a failing alarm subscribe is retried at least + /// every 4 minutes. + private const int MaxAlarmRetrySkipTicks = 7; + /// /// Gets or sets the timer scheduler for scheduling reconnection attempts. /// @@ -690,6 +701,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// private void ReconcileSubscription() { + ReconcileAlarmSubscription(); if (_driver is not ISubscribable) return; if (_desiredRefs.Count == 0 || _subscriptionHandle is not null) return; @@ -699,6 +711,43 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Self.Tell(new Subscribe(_desiredRefs, _desiredInterval)); } + /// + /// #662 — the native-alarm half of . The data reconcile above never + /// looked at alarms, so an alarm subscribe that failed on (re)connect — e.g. a session not yet ready + /// after an outage — left the feed gated for the life of the connection, with the driver Healthy and + /// its data streaming. Re-asserts both halves the feed needs: the OnAlarmEvent handler + /// (idempotent attach) and a subscription matching the desired set ( + /// is a no-op while the live handle already matches). Gated on + a + /// non-empty desired set, so it costs nothing on a driver with no alarm tags. + /// A persistently failing subscribe backs off: after the n-th consecutive failure the reconcile + /// skips 2^(n-1) − 1 ticks, capped at , because a HANGING subscribe + /// holds the mailbox for its full 10 s bound (writes queued behind it can miss the host's 8 s Ask). A + /// Connected entry or a deploy's desired-set push still retries immediately — only the timer backs off. + /// + private void ReconcileAlarmSubscription() + { + if (_driver is not IAlarmSource || _desiredAlarmRefs.Count == 0) return; + if (_alarmEventHandler is null) + { + // Unreachable while every Connected entry attaches — so if this ever logs, something regressed #662. + _log.Warning("DriverInstance {Id}: connected with desired alarm refs but no native-alarm handler — re-attaching (periodic reconcile)", + _driverInstanceId); + AttachAlarmSource(); + } + if (_alarmRetrySkipTicks > 0) + { + _alarmRetrySkipTicks--; + return; + } + if (AlarmSubscriptionIsStale()) + { + _log.Info( + "DriverInstance {Id}: connected with {Count} desired alarm ref(s) but no matching native-alarm subscription — re-subscribing (periodic reconcile)", + _driverInstanceId, _desiredAlarmRefs.Count); + Self.Tell(new SubscribeAlarms()); + } + } + private void Reconnecting() { // #477 — announce comms loss to the host so it annotates this driver's native alarm conditions Bad @@ -1002,7 +1051,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } catch (Exception ex) { - DetachSubscription(); + DetachDataSubscription(); _log.Warning(ex, "DriverInstance {Id}: subscribe failed", _driverInstanceId); replyTo.Tell(new SubscriptionFailed(ex.Message)); } @@ -1012,7 +1061,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { if (_driver is not ISubscribable subscribable || _subscriptionHandle is null) { - DetachSubscription(); + DetachDataSubscription(); return; } try @@ -1031,17 +1080,32 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } finally { - DetachSubscription(); + DetachDataSubscription(); } } - /// Tear down the data-change (per-tag AND batch) + native-alarm event handlers + null the handle. - /// Called from the Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale - /// handler doesn't push data-change / alarm events to an actor that has lost its driver connection. + /// Tear down the data-change (per-tag AND batch) + native-alarm event handlers + null both handles. + /// Called on PostStop and on Connected → Reconnecting transitions so a stale handler doesn't push + /// data-change / alarm events to an actor that has lost its driver connection. + /// Not for the data Unsubscribe / re-subscribe path — that uses + /// (#662). + private void DetachSubscription() + { + DetachDataSubscription(); + DetachAlarmSource(); + } + + /// Tear down the data-change (per-tag AND batch) handlers + null the data handle, leaving the + /// native-alarm handler and handle alone. + /// #662: the data Unsubscribe / re-subscribe path used to run the full , + /// so every deploy that re-subscribed a Connected driver's DATA also unhooked OnAlarmEvent and + /// forgot the alarm handle — the alarm subscription still logged "established" but its events reached + /// nobody until a reconnect or respawn. The two subscriptions are independent at the driver; only a lost + /// connection ends both. /// Both data-change fields are cleared unconditionally even though only one is ever attached — the /// IDriver object outlives the actor when the host respawns a child around it, so a handler left on the /// driver's event would keep Telling a dead actor. - private void DetachSubscription() + private void DetachDataSubscription() { if (_driver is ISubscribable subscribable && _dataChangeHandler is not null) { @@ -1054,7 +1118,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } _batchChangeHandler = null; _subscriptionHandle = null; - DetachAlarmSource(); } /// Subscribe the driver's native alarm event (if it is an ), @@ -1206,6 +1269,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // across reconnects. The gate (Count > 0) and the one-shot fan-out are unchanged. _alarmSubscriptionHandle = null; _subscribedAlarmRefs = Array.Empty(); + _alarmSubscribeFailures = 0; + _alarmRetrySkipTicks = 0; } /// Establish the native-alarm subscription that un-gates an driver's @@ -1229,7 +1294,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Calls the driver's (bounded) to register the /// alarm subscription that un-gates its native feed, caching the returned handle. Re-checks the guard /// (the desired set may have cleared, or another SubscribeAlarms may have already established a handle, - /// while this was queued). Failures are logged and retried on the next Connected entry — the feed simply + /// while this was queued). Failures are logged and retried on the next Connected entry or health-poll reconcile tick (#662) — the feed /// stays gated until then. private async Task HandleSubscribeAlarmsAsync(SubscribeAlarms _) { @@ -1271,13 +1336,24 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers async ct => await src.SubscribeAlarmsAsync(refs, ct), cts.Token); _subscribedAlarmRefs = refs; - _log.Info("DriverInstance {Id}: native-alarm subscription established for {Count} alarm ref(s) ({Diag})", - _driverInstanceId, refs.Count, _alarmSubscriptionHandle.DiagnosticId); + _log.Info("DriverInstance {Id}: native-alarm subscription established for {Count} alarm ref(s) ({Diag}){After}", + _driverInstanceId, refs.Count, _alarmSubscriptionHandle.DiagnosticId, + _alarmSubscribeFailures > 0 ? $" after {_alarmSubscribeFailures} failed attempt(s)" : ""); + _alarmSubscribeFailures = 0; + _alarmRetrySkipTicks = 0; } catch (Exception ex) { - _log.Warning(ex, "DriverInstance {Id}: native-alarm subscription failed — feed stays gated until reconnect", - _driverInstanceId); + _alarmSubscribeFailures++; + _alarmRetrySkipTicks = Math.Min((1 << Math.Min(_alarmSubscribeFailures - 1, 30)) - 1, MaxAlarmRetrySkipTicks); + // The stack once per failure streak; the retries that follow repeat the same cause, so they log + // just the message rather than a full trace every tick for the length of an outage. + if (_alarmSubscribeFailures == 1) + _log.Warning(ex, "DriverInstance {Id}: native-alarm subscription failed — feed stays gated until the periodic reconcile retries it", + _driverInstanceId); + else + _log.Warning("DriverInstance {Id}: native-alarm subscription retry {Attempt} failed ({Reason}) — next retry in {Ticks} health-poll tick(s)", + _driverInstanceId, _alarmSubscribeFailures, ex.Message, _alarmRetrySkipTicks + 1); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs index 1e1ce406..c3895b53 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs @@ -218,6 +218,88 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100)); } + /// + /// Gitea #662 case 1 — re-subscribing a Connected driver's DATA (what every deploy's + /// SetDesiredSubscriptions push does) must leave the native-alarm feed alone. The data + /// re-subscribe drops the prior data subscription first, and that teardown used to run the full + /// detach — unhooking OnAlarmEvent and forgetting the alarm handle — so the alarm subscription + /// stayed "established" while every event it carried reached nobody until a reconnect. + /// + [Fact] + public async Task A_data_re_subscribe_keeps_the_native_alarm_feed_attached() + { + var driver = new AlarmSourceStubDriver(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2)); + actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( + new[] { "tag-a" }, TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" })); + AwaitCondition(() => driver.SubscribeAlarmsCallCount == 1, TimeSpan.FromSeconds(2)); + + // A second data subscribe while the first handle is live — the drop-then-resubscribe path. + await actor.Ask( + new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)), + TimeSpan.FromSeconds(3)); + + driver.AlarmSubscriberCount.ShouldBe(1, "the data re-subscribe must not unhook OnAlarmEvent"); + driver.SubscribeAlarmsCallCount.ShouldBe(1, + "the alarm handle is still live, so nothing should re-issue (and duplicate) the driver-side subscription"); + + driver.RaiseAlarm(new AlarmEventArgs( + new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after re-subscribe", AlarmSeverity.High, + DateTime.UtcNow, Kind: AlarmTransitionKind.Raise)); + parent.FishForMessage(_ => true, TimeSpan.FromSeconds(2)) + .Args.ConditionId.ShouldBe("Temp.HiHi"); + } + + /// + /// Gitea #662 case 2 — an alarm subscribe that fails on (re)connect (a session not yet ready after an + /// outage) is retried by the periodic reconcile while the driver stays Connected. Before #662 the + /// reconcile looked at data only, so the feed stayed gated for the life of the connection. Nothing + /// else here can produce the second call: no reconnect, no deploy. + /// + /// Leading failures; 3 crosses the backoff (skips 0, 1, then 3 ticks), proving the + /// retries keep coming rather than giving up. + [Theory] + [InlineData(1)] + [InlineData(3)] + public void A_failed_alarm_subscribe_is_retried_by_the_periodic_reconcile(int failures) + { + var driver = new AlarmSourceStubDriver { AlarmSubscribeFailuresBeforeSuccess = failures }; + var publisher = new CountingHealthPublisher(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(100))); + + actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( + Array.Empty(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" })); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + AwaitCondition(() => driver.SubscribeAlarmsCallCount >= 1, PresenceBudget); + AwaitCondition(() => driver.SubscribeAlarmsCallCount >= failures + 1, PresenceBudget); + driver.LastAlarmRefs.ShouldBe(new[] { "Temp.HiHi" }); + + driver.RaiseAlarm(new AlarmEventArgs( + new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after retry", AlarmSeverity.High, + DateTime.UtcNow, Kind: AlarmTransitionKind.Raise)); + parent.FishForMessage(_ => true, TimeSpan.FromSeconds(2)) + .Args.ConditionId.ShouldBe("Temp.HiHi"); + + // Converged: a live, matching handle is left alone on later ticks. POSITIVE CONTROL: each tick + // publishes one health snapshot, so waiting for four more makes the unchanged count mean "the + // reconcile ran and declined", not "no tick had fired yet". It relies on the base StubDriver's + // GetHealth() stamping UtcNow, which defeats the publish dedup so EVERY tick publishes — a stub with + // stable health would publish once and this wait would time out (loudly, not vacuously). + var settled = driver.SubscribeAlarmsCallCount; + var before = publisher.Count; + AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget); + driver.SubscribeAlarmsCallCount.ShouldBe(settled, "a live, matching alarm handle must not be re-subscribed every tick"); + } + /// /// After a full reconnect cycle (Connected → Reconnecting → Connected), a single raised alarm still /// yields EXACTLY ONE — the @@ -354,10 +436,16 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { LastAlarmRefs = sourceNodeIds; - Interlocked.Increment(ref _subscribeAlarmsCallCount); + var call = Interlocked.Increment(ref _subscribeAlarmsCallCount); + if (call <= AlarmSubscribeFailuresBeforeSuccess) + throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)"); return Task.FromResult(new StubAlarmHandle()); } + /// How many leading calls throw — the "session not ready + /// after an outage" shape from #662. + public int AlarmSubscribeFailuresBeforeSuccess { get; init; } + /// Cancels an alarm subscription. public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; @@ -416,6 +504,28 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase } } + /// Counts health-snapshot publishes — one per processed health-poll tick. + 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, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null, + SkippedTagReport? skippedTags = null, + IReadOnlyDictionary? diagnostics = null) + => Interlocked.Increment(ref _count); + } + /// Minimal for building . private sealed class StubAlarmHandle : IAlarmSubscriptionHandle { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmFeedSurvivesDeployTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmFeedSurvivesDeployTests.cs new file mode 100644 index 00000000..d2b447f3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmFeedSurvivesDeployTests.cs @@ -0,0 +1,87 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Gitea #662 — a native-alarm feed must survive a deploy that does NOT respawn its driver, end to end +/// through the real and its real +/// child. +/// +/// +/// Found live on the docker-dev rig: a deploy that only disabled ANOTHER driver re-pushed the desired set to +/// an unchanged OpcUaClient driver, whose data re-subscribe tore down the alarm event handler too. The +/// condition then froze while upstream kept emitting, and only a respawn brought it back. Every existing +/// end-to-end alarm test drives the driver directly (the host's driver is Enabled=false), so none of +/// them could see the instance actor drop the feed. +/// +public sealed class NativeAlarmFeedSurvivesDeployTests : RuntimeActorTestBase +{ + 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 NodeId TestNode = NodeId.Parse("node-1:4053"); + + private const string DriverId = "DI-1"; + private const string AlarmXPath = "Plant/Modbus/dev1/alarmX"; + private const string AlarmConfig = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}"""; + + private static readonly (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] + RawTags = + [ + (DriverId, "Modbus", "dev1", "speed", "Float", false), + (DriverId, "Modbus", "dev1", "alarmX", "Float", false), + ]; + + private static readonly Dictionary TagConfigs = new() { ["alarmX"] = AlarmConfig }; + + [Fact] + public void An_upstream_alarm_still_reaches_its_condition_after_a_non_respawning_deploy() + { + var db = NewInMemoryDbFactory(); + var factory = new RecordingSubscribableDriverFactory("Modbus"); + var revA = V3ArtifactSeeder.SeedV3Deployment( + db, RevA, RawTags, Array.Empty<(string, string, string, string, string?)>(), + enabled: true, tagConfigByTag: TagConfigs); + var (host, publish, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, revA, RevA, factory); + + RecordingSubscribableDriver driver = null!; + AwaitAssert(() => + { + factory.Created.Count.ShouldBe(1); + driver = factory.Created[0]; + driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(1); + driver.LastSubscribedAlarmRefs.ShouldBe(new[] { AlarmXPath }); + driver.AlarmSubscriberCount.ShouldBe(1); + }, duration: PresenceBudget); + var dataSubscribesBefore = driver.SubscribeCount; + + // Revision B only ADDS another driver: DI-1's merged DriverConfig is byte-identical, so it is kept, + // and the bulk push re-sends its (unchanged) desired set — which re-subscribes its data. + var revB = V3ArtifactSeeder.SeedV3Deployment( + db, RevB, RawTags, Array.Empty<(string, string, string, string, string?)>(), + enabled: true, tagConfigByTag: TagConfigs, taglessDrivers: ["DI-2"]); + host.Tell(new DispatchDeployment(revB, RevB, CorrelationId.NewId())); + + AwaitAssert(() => + { + driver.SubscribeCount.ShouldBeGreaterThan(dataSubscribesBefore, + "precondition: the deploy must re-subscribe DI-1's data, or this test exercises nothing"); + }, duration: PresenceBudget); + driver.InitializeCount.ShouldBe(1, "precondition: DI-1 must NOT have been respawned or re-initialised"); + driver.ShutdownCount.ShouldBe(0); + factory.Created[0].ShouldBeSameAs(driver); + + driver.AlarmSubscriberCount.ShouldBe(1, "the alarm event handler must survive the data re-subscribe"); + driver.FireAlarm(AlarmXPath, AlarmTransitionKind.Raise); + + var update = publish.FishForMessage( + m => m.AlarmNodeId == AlarmXPath && m.State.Active, PresenceBudget); + update.State.Acknowledged.ShouldBeFalse(); + } +} 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 3c4783d0..627a1c38 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 @@ -343,9 +343,15 @@ internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAla /// Raises ; present so the event is not merely written-to-never-read. /// The condition id to raise. - public void FireAlarm(string conditionId) => + /// The transition kind to report. + public void FireAlarm(string conditionId, AlarmTransitionKind kind = AlarmTransitionKind.Unspecified) => OnAlarmEvent?.Invoke(this, new AlarmEventArgs( - _alarmHandle, conditionId, conditionId, "AlarmCondition", "test", AlarmSeverity.High, DateTime.UtcNow)); + _alarmHandle, conditionId, conditionId, "AlarmCondition", "test", AlarmSeverity.High, DateTime.UtcNow, + Kind: kind)); + + /// Live subscribers on — an alarm subscription with no handler + /// attached delivers nothing (#662), so this is the other half of "the feed is up". + public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0; private sealed class RecordingAlarmHandle : IAlarmSubscriptionHandle {