From 06ef1779bd72b06b25e1e78ae21392880219ec69 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 18:02:42 -0400 Subject: [PATCH 1/3] fix(dcl): deliver initial-read seed value after subscription registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataConnectionActor seeded a tag's initial value by Tell-ing TagValueReceived from HandleSubscribe's background task, which runs BEFORE HandleSubscribeCompleted registers the instance's tags in _subscriptionsByInstance. HandleTagValueReceived's fan-out then found no subscriber and dropped the value. A tag that soon gets a data-change notification recovers, but a STATIC tag (e.g. an idle MES field that never changes) was left Uncertain forever — the dropped seed was its only value. Seeds now ride back on SubscribeCompleted and are delivered after registration, reusing HandleTagValueReceived's generation guard, fan-out and quality accounting. +1 regression test (DCL026). --- .../Actors/DataConnectionActor.cs | 41 ++++++++++++++++--- .../DataConnectionActorTests.cs | 37 +++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index fa6e8e34..b334d52f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -713,9 +713,18 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers } } - // Initial read — seed current values for resolved tags so the Instance Actor - // doesn't stay Uncertain until the next OPC UA data change notification. - // Tell is thread-safe, so seeded values are delivered directly as messages. + // Initial read — capture current values for resolved tags so the Instance + // Actor doesn't stay Uncertain until the next data-change notification. + // DataConnectionLayer-026: these are NOT delivered here. Emitting a + // TagValueReceived now (inside the background subscribe task) races ahead of + // the SubscribeCompleted that registers this instance's tags in + // _subscriptionsByInstance, so HandleTagValueReceived's fan-out finds no + // subscriber for the tag and drops the value. That's harmless for a tag that + // soon gets a real change notification, but for a STATIC tag (e.g. an idle + // MES field that never changes) the dropped seed is the only value it will + // ever produce — leaving the attribute Uncertain forever. So the seeds ride + // back on SubscribeCompleted and are delivered after registration. + var seedValues = new List(tagsToSeed.Count); foreach (var tagPath in tagsToSeed) { try @@ -723,7 +732,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers var readResult = await _adapter.ReadAsync(tagPath); if (readResult.Success && readResult.Value != null) { - self.Tell(new TagValueReceived(tagPath, readResult.Value, generation)); + seedValues.Add(new SeededValue(tagPath, readResult.Value)); } } catch @@ -732,7 +741,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers } } - return new SubscribeCompleted(request, sender, results); + return new SubscribeCompleted(request, sender, results, seedValues); }).PipeTo(self); } @@ -879,6 +888,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers } } + // DataConnectionLayer-026: now that every tag is registered in + // _subscriptionsByInstance, deliver the values captured by the initial read. + // Re-entering via Self reuses HandleTagValueReceived's generation guard, fan-out + // and quality accounting — and crucially runs AFTER registration, so the value + // is no longer dropped. Only resolved tags (in _subscriptionIds) are seeded; an + // unresolved tag already got a Bad-quality update above and must not be masked. + if (!connectionLevelFailure) + { + foreach (var seed in msg.SeedValues) + { + if (_subscriptionIds.ContainsKey(seed.TagPath)) + Self.Tell(new TagValueReceived(seed.TagPath, seed.Value, _adapterGeneration)); + } + } + // Start the tag-resolution retry timer if any tags are unresolved. // DataConnectionLayer-022: StartPeriodicTimer with an existing key CANCELS // and replaces the prior timer, so a fan-out of SubscribeTagsRequests @@ -1641,7 +1665,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers string TagPath, bool AlreadySubscribed, bool Success, string? SubscriptionId, string? Error, bool ConnectionLevelFailure = false); internal record SubscribeCompleted( - SubscribeTagsRequest Request, IActorRef ReplyTo, IReadOnlyList Results); + SubscribeTagsRequest Request, IActorRef ReplyTo, IReadOnlyList Results, + IReadOnlyList SeedValues); + + /// An initial-read value captured during subscribe, delivered after the + /// instance's tags are registered for fan-out (DataConnectionLayer-026). + internal record SeededValue(string TagPath, TagValue Value); internal record AlarmTransitionReceived(NativeAlarmTransition Transition, int AdapterGeneration); internal record AlarmSubscribeCompleted( string SourceReference, bool Success, string? SubscriptionId, string? Error, diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs index f97b5e90..8468ea50 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs @@ -546,6 +546,43 @@ public class DataConnectionActorTests : TestKit Assert.True(ack.Success); } + [Fact] + public async Task DCL026_StaticTagSeedValue_IsDeliveredAfterRegistration() + { + // Regression test for DataConnectionLayer-026. The initial-read seed value used to + // be emitted (TagValueReceived) from HandleSubscribe's background task BEFORE + // HandleSubscribeCompleted registered the instance's tags in + // _subscriptionsByInstance. HandleTagValueReceived's fan-out then found no + // subscriber for the tag and silently dropped the value. A tag that soon gets a + // real data-change notification recovers, but a STATIC tag (subscribe succeeds, + // callback never fires again — e.g. an idle MES field) was left Uncertain forever. + // After the fix the seed rides on SubscribeCompleted and is delivered AFTER + // registration, so the subscriber receives it. + _mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + _mockAdapter.Status.Returns(ConnectionHealth.Connected); + // Subscribe succeeds; the adapter never invokes the value callback (a static tag). + _mockAdapter.SubscribeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult("sub-static")); + // The gateway returns a Good current value for the static tag. + _mockAdapter.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new ReadResult(true, + new TagValue("Left54321", QualityCode.Good, DateTimeOffset.UtcNow), null)); + + var actor = CreateConnectionActor("dcl026-static-seed"); + await Task.Delay(300); // reach Connected state + + actor.Tell(new SubscribeTagsRequest( + "c1", "inst1", "dcl026-static-seed", + ["MESReceiver_023.MoveInMesContainerNum"], DateTimeOffset.UtcNow)); + + // The seeded value must reach the subscriber (was dropped pre-fix). + var update = FishForMessage(_ => true, TimeSpan.FromSeconds(5)); + Assert.Equal("MESReceiver_023.MoveInMesContainerNum", update.TagPath); + Assert.Equal(QualityCode.Good, update.Quality); + Assert.Equal("Left54321", update.Value); + } + [Fact] public async Task DCL004_ConnectionLevelSubscribeFailure_TriggersReconnect_NotTagRetry() { From 361e7f41ba86fa99028207a48539b5ca0b7a1716 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 19:33:41 -0400 Subject: [PATCH 2/3] fix(dcl): broadcast SnapshotComplete sentinel to all alarm subscribers The MxGateway alarm mapper emits the SnapshotComplete framing sentinel with empty SourceReference/SourceObjectReference. HandleAlarmTransitionReceived routed every transition by prefix match against the subscriber's source, so the empty-ref sentinel ('' .StartsWith(".") == false) was dropped for any specific source. The NativeAlarmActor buffers snapshot conditions and only flushes them on SnapshotComplete, so statically-active native alarms delivered only in the initial snapshot (no later live transition) never surfaced. Broadcast the SnapshotComplete sentinel to all alarm subscribers (bypassing the source match + type filter) so each NativeAlarmActor's snapshot swap completes. Adds a regression test using the real empty-ref sentinel against a specific (prefix) source. --- .../Actors/DataConnectionActor.cs | 20 +++++++++++ .../DataConnectionActorAlarmTests.cs | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index b334d52f..24b71a04 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -1570,6 +1570,26 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers var transition = msg.Transition; var notified = new HashSet(); + + // A SnapshotComplete is a connection-wide framing sentinel, not a real + // condition: the mapper emits it with an empty SourceReference / + // SourceObjectReference. It must reach EVERY alarm subscriber so each + // NativeAlarmActor can atomically swap in the snapshot it just buffered. + // The per-source prefix match below would drop it ("".StartsWith(".") + // is false), which would strand statically-active conditions that are only + // delivered in the snapshot (no later live transition) — they would buffer + // forever and never surface. Broadcast the sentinel to all subscribers, + // bypassing the source match and the condition-type filter (the sentinel + // carries no condition; the buffered entries were already filtered). + if (transition.Kind == AlarmTransitionKind.SnapshotComplete) + { + foreach (var subs in _alarmSourceSubscribers.Values) + foreach (var sub in subs) + if (notified.Add(sub)) + sub.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition)); + return; + } + foreach (var (sourceRef, subs) in _alarmSourceSubscribers) { // A subscriber bound to source S receives a transition whose source diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs index cd685897..b727106e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs @@ -195,4 +195,40 @@ public class DataConnectionActorAlarmTests : TestKit "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "")); ExpectMsg(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete); } + + [Fact] + public void SubscribeAlarms_RealEmptyRefSnapshotComplete_IsBroadcastToSpecificSource() + { + // Regression: the real MxGatewayAlarmMapper.SnapshotComplete() emits the + // sentinel with EMPTY SourceReference / SourceObjectReference. With a + // specific (prefix) source like "Reactor." the per-source match + // ("".StartsWith("Reactor.") == false) used to drop it, stranding the + // buffered snapshot in the NativeAlarmActor forever — statically-active + // conditions (delivered only in the snapshot) never surfaced. The sentinel + // must be broadcast to every subscriber so the snapshot swap completes. + var (adapter, getCb) = BuildAlarmAdapter(); + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "MxGateway"))); + + actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Reactor.", null, DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + var cb = getCb(); + Assert.NotNull(cb); + + // Active alarm under the source arrives in the snapshot and routes by prefix. + cb!(new NativeAlarmTransition( + "Galaxy!Area.Reactor.HeartbeatTimeoutAlarm", "Reactor.HeartbeatTimeoutAlarm", "Syst", + AlarmTransitionKind.Snapshot, + new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 400), + "Area", "", "", "", "", null, DateTimeOffset.UtcNow, "", "")); + ExpectMsg(u => + u.Transition.Kind == AlarmTransitionKind.Snapshot && + u.Transition.SourceObjectReference == "Reactor.HeartbeatTimeoutAlarm"); + + // Real sentinel with EMPTY refs — must still reach the "Reactor." subscriber. + cb!(new NativeAlarmTransition("", "", "", AlarmTransitionKind.SnapshotComplete, + new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), + "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "")); + ExpectMsg(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete); + } } From 9b78e6071d50f76c166a5120286a0b4247ac08c2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 19:46:44 -0400 Subject: [PATCH 3/3] fix(dcl): identify MxGateway native alarms by object-relative reference Surface native (Galaxy/MxGateway) alarms by their object-relative reference (e.g. "Z28061.HeartbeatTimeoutAlarm") instead of the gateway's full provider reference ("Galaxy!.."). The area is already preserved in Category and the object reference is globally unique within the galaxy, so the full provider prefix added only noise to the alarm identity operators see. MxGatewayAlarmMapper.MapTransition/MapSnapshot now set SourceReference from SourceObjectReference, falling back to AlarmFullReference only when the gateway omits the object reference. +2 mapper tests; full DCL suite green (158). --- .../Adapters/MxGatewayAlarmMapper.cs | 14 +++++- .../MxGatewayAlarmMapperTests.cs | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs index e3a7b7c5..c91d6765 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs @@ -86,7 +86,14 @@ public static class MxGatewayAlarmMapper /// The gateway alarm transition event proto message to map. /// The protocol-neutral . public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body) => new( - SourceReference: body.AlarmFullReference, + // Identify the condition by the object-relative reference (e.g. + // "Z28061.HeartbeatTimeoutAlarm") rather than the gateway's full provider + // reference ("Galaxy!.."). The area is preserved in + // Category; the object reference is globally unique within the galaxy and + // is the form operators expect. Falls back to the full reference only if + // the gateway omits the object reference. + SourceReference: string.IsNullOrEmpty(body.SourceObjectReference) + ? body.AlarmFullReference : body.SourceObjectReference, SourceObjectReference: body.SourceObjectReference, AlarmTypeName: body.AlarmTypeName, Kind: MapKind(body.TransitionKind), @@ -112,7 +119,10 @@ public static class MxGatewayAlarmMapper /// The active alarm snapshot proto message to map. /// A with AlarmTransitionKind.Snapshot. public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot) => new( - SourceReference: snapshot.AlarmFullReference, + // See MapTransition: identify by the object-relative reference, not the + // full "Galaxy!.." provider reference. + SourceReference: string.IsNullOrEmpty(snapshot.SourceObjectReference) + ? snapshot.AlarmFullReference : snapshot.SourceObjectReference, SourceObjectReference: snapshot.SourceObjectReference, AlarmTypeName: snapshot.AlarmTypeName, Kind: AlarmTransitionKind.Snapshot, diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs index 5f8eaf3e..438ffbdc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs @@ -65,6 +65,54 @@ public class MxGatewayAlarmMapperTests Assert.Equal(1000, t.Condition.Severity); } + [Fact] + public void SourceReference_IsObjectRelative_NotFullProviderReference() + { + // The condition identity surfaced upward is the object-relative reference + // (e.g. "Z28061.HeartbeatTimeoutAlarm"), not the gateway's full provider + // reference ("Galaxy!.."). Area lives in Category. + var snap = new ActiveAlarmSnapshot + { + AlarmFullReference = "Galaxy!CVDAisle_1.Z28061.HeartbeatTimeoutAlarm", + SourceObjectReference = "Z28061.HeartbeatTimeoutAlarm", + AlarmTypeName = "Syst", + Category = "CVDAisle_1", + CurrentState = ProtoConditionState.Active, + Severity = 400 + }; + var ev = new OnAlarmTransitionEvent + { + AlarmFullReference = "Galaxy!CVDAisle_1.Z28061.HeartbeatTimeoutAlarm", + SourceObjectReference = "Z28061.HeartbeatTimeoutAlarm", + AlarmTypeName = "Syst", + TransitionKind = ProtoTransitionKind.Raise, + Severity = 400 + }; + + var snapT = MxGatewayAlarmMapper.MapSnapshot(snap); + var liveT = MxGatewayAlarmMapper.MapTransition(ev); + + Assert.Equal("Z28061.HeartbeatTimeoutAlarm", snapT.SourceReference); + Assert.Equal("Z28061.HeartbeatTimeoutAlarm", liveT.SourceReference); + Assert.Equal("CVDAisle_1", snapT.Category); + } + + [Fact] + public void SourceReference_FallsBackToFullReference_WhenObjectReferenceEmpty() + { + var snap = new ActiveAlarmSnapshot + { + AlarmFullReference = "Galaxy!Area.Obj.Alarm", + SourceObjectReference = "", + CurrentState = ProtoConditionState.Active, + Severity = 100 + }; + + var t = MxGatewayAlarmMapper.MapSnapshot(snap); + + Assert.Equal("Galaxy!Area.Obj.Alarm", t.SourceReference); + } + // ── CurrentValue / LimitValue (M2.13 / #27) ────────────────────────────── [Fact]