From dbf44b9e10072d2e32b65bbc8fd0ce772db4de0e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 06:08:21 -0400 Subject: [PATCH] =?UTF-8?q?fix(siteruntime):=20M2.11=20=E2=80=94=20unknown?= =?UTF-8?q?-instance=20debug=20snapshot=20returns=20InstanceNotFound=3Dtru?= =?UTF-8?q?e=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RouteDebugSnapshot and RouteDebugViewSubscribe on DeploymentManagerActor previously returned an empty DebugViewSnapshot for unknown instances, indistinguishable from a deployed-but-empty instance. Callers had no way to differentiate "not deployed here" from "deployed, no data yet." Approach — additive field on existing message contract: Added `bool InstanceNotFound = false` as an optional trailing parameter to DebugViewSnapshot (Commons). All existing positional constructor calls and serialized wire frames are unaffected (default = false). A dedicated new message type was considered but rejected: the ClusterClient channel and DebugStreamService TCS are already typed on DebugViewSnapshot, and a second reply union would require wider changes for zero additive-safety gain. Changes: - Commons/DebugViewSnapshot: add InstanceNotFound = false (additive) - DeploymentManagerActor: set InstanceNotFound=true in both unknown- instance branches (RouteDebugViewSubscribe, RouteDebugSnapshot) - DebugStreamBridgeActor: when snapshot.InstanceNotFound, forward it to _onEvent (resolves the TCS) then stop cleanly; no gRPC stream opened - DebugView.razor: check session.InitialSnapshot.InstanceNotFound after connect and show a clear "not deployed on this site" error toast - 3 new tests in DeploymentManagerActorTests covering: unknown→snapshot, unknown→subscribe, known-empty→InstanceNotFound stays false --- .../Pages/Deployment/DebugView.razor | 12 ++++ .../Messages/DebugView/DebugViewSnapshot.cs | 32 +++++++++- .../Actors/DebugStreamBridgeActor.cs | 16 ++++- .../Actors/DeploymentManagerActor.cs | 10 ++- .../Actors/DeploymentManagerActorTests.cs | 61 +++++++++++++++++++ 5 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor index 8280a50a..7a98209b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -445,6 +445,18 @@ }); }); + // M2.11: the site returns InstanceNotFound=true when the instance is + // not deployed there (e.g. deployment not yet pushed, or wrong site). + if (session.InitialSnapshot.InstanceNotFound) + { + DebugStreamService.StopStream(session.SessionId); + _toast.ShowError( + $"Instance is not deployed on that site. " + + $"Deploy it first or choose the correct site."); + _connecting = false; + return; + } + _session = session; // Populate initial state from snapshot diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DebugView/DebugViewSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DebugView/DebugViewSnapshot.cs index f92a90cd..ba1f8695 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DebugView/DebugViewSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DebugView/DebugViewSnapshot.cs @@ -2,8 +2,38 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +/// +/// Snapshot of an instance's debug state returned in response to a +/// or . +/// +/// +/// +/// Additive-only contract (M2.11): is an +/// optional trailing parameter with a default of so every +/// existing positional constructor call and every existing serialized wire frame +/// remains valid. Callers that receive a snapshot with +/// InstanceNotFound = true know the instance was unknown on the site and +/// should distinguish that from a deployed-but-empty instance +/// (InstanceNotFound = false, empty and +/// ). +/// +/// +/// A new dedicated message type (DebugViewInstanceNotFound) was +/// considered but rejected: the ClusterClient / ClusterClientReceptionist +/// channel is typed on the request side and the bridge actor is already +/// pattern-matching on DebugViewSnapshot for the initial-snapshot TCS +/// in DebugStreamService. Introducing a second reply type would require +/// every consumer to handle an additional Ask result union — more change +/// for no additive-safety gain. The defaulted field is strictly additive and +/// keeps all call sites untouched. +/// +/// public record DebugViewSnapshot( string InstanceUniqueName, IReadOnlyList AttributeValues, IReadOnlyList AlarmStates, - DateTimeOffset SnapshotTimestamp); + DateTimeOffset SnapshotTimestamp, + // M2.11 — additive field: true when the requested instance is not registered + // on this site. Defaults to false so all existing call sites and wire + // frames are unaffected. + bool InstanceNotFound = false); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs index c4474739..7ac30fc1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs @@ -85,9 +85,23 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers _grpcNodeAAddress = grpcNodeAAddress; _grpcNodeBAddress = grpcNodeBAddress; - // Initial snapshot response from the site (via ClusterClient) + // Initial snapshot response from the site (via ClusterClient). + // M2.11: if the site reports InstanceNotFound=true the instance is not + // deployed there. Forward the snapshot (with InstanceNotFound=true) to + // _onEvent so DebugStreamService's TCS resolves and the caller can + // inspect the flag; then stop cleanly without opening a gRPC stream. Receive(snapshot => { + if (snapshot.InstanceNotFound) + { + _log.Warning("Instance {0} is not deployed on site; terminating debug stream", + _instanceUniqueName); + _stopped = true; + _onEvent(snapshot); // resolves the snapshot TCS with InstanceNotFound=true + Context.Stop(Self); + return; + } + _log.Info("Received initial snapshot for {0} ({1} attrs, {2} alarms)", _instanceUniqueName, snapshot.AttributeValues.Count, snapshot.AlarmStates.Count); _onEvent(snapshot); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 10a6aae6..d412572e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -895,11 +895,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers } else { + // M2.11: set InstanceNotFound=true so the caller can distinguish + // "not deployed on this site" from a deployed-but-empty instance. _logger.LogWarning( "Debug view subscribe for unknown instance {Instance}", request.InstanceUniqueName); Sender.Tell(new DebugViewSnapshot( request.InstanceUniqueName, Array.Empty(), - Array.Empty(), DateTimeOffset.UtcNow)); + Array.Empty(), DateTimeOffset.UtcNow, + InstanceNotFound: true)); } } @@ -919,11 +922,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers } else { + // M2.11: set InstanceNotFound=true so the caller can distinguish + // "not deployed on this site" from a deployed-but-empty instance. _logger.LogWarning( "Debug snapshot for unknown instance {Instance}", request.InstanceUniqueName); Sender.Tell(new DebugViewSnapshot( request.InstanceUniqueName, Array.Empty(), - Array.Empty(), DateTimeOffset.UtcNow)); + Array.Empty(), DateTimeOffset.UtcNow, + InstanceNotFound: true)); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index 6b57e936..062592af 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -3,6 +3,7 @@ using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; @@ -387,4 +388,64 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("route-corr-2", response.CorrelationId); Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}"); } + + // ── M2.11: Debug-view routing — unknown-instance not-found signal ── + + [Fact] + public async Task RouteDebugSnapshot_UnknownInstance_SetsInstanceNotFound() + { + // An instance that was never deployed to this site must return a + // DebugViewSnapshot with InstanceNotFound=true so the caller can + // distinguish "not deployed here" from a deployed-but-empty instance. + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new DebugSnapshotRequest("NeverDeployed", "snap-corr-1")); + + var snapshot = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("NeverDeployed", snapshot.InstanceUniqueName); + Assert.True(snapshot.InstanceNotFound, + "Expected InstanceNotFound=true for an instance not registered on this site."); + Assert.Empty(snapshot.AttributeValues); + Assert.Empty(snapshot.AlarmStates); + } + + [Fact] + public async Task RouteDebugViewSubscribe_UnknownInstance_SetsInstanceNotFound() + { + // Same contract for the subscribe path: unknown instance → InstanceNotFound=true. + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new SubscribeDebugViewRequest("NeverDeployed2", "sub-corr-1")); + + var snapshot = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("NeverDeployed2", snapshot.InstanceUniqueName); + Assert.True(snapshot.InstanceNotFound, + "Expected InstanceNotFound=true for an instance not registered on this site."); + Assert.Empty(snapshot.AttributeValues); + Assert.Empty(snapshot.AlarmStates); + } + + [Fact] + public async Task RouteDebugSnapshot_KnownButEmptyInstance_DoesNotSetInstanceNotFound() + { + // A deployed instance with no runtime data yet must return an empty + // snapshot with InstanceNotFound=false — the known-empty path is unchanged. + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new DeployInstanceCommand( + "dep-dbg", "EmptyPump", "sha256:dbg", + MakeConfigJson("EmptyPump"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(800); // let InstanceActor spin up + + actor.Tell(new DebugSnapshotRequest("EmptyPump", "snap-corr-2")); + + var snapshot = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("EmptyPump", snapshot.InstanceUniqueName); + Assert.False(snapshot.InstanceNotFound, + "A deployed (but empty) instance must NOT set InstanceNotFound."); + } }