fix(siteruntime): M2.11 — unknown-instance debug snapshot returns InstanceNotFound=true (#24)

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
This commit is contained in:
Joseph Doherty
2026-06-16 06:08:21 -04:00
parent 9cd62aa5b4
commit dbf44b9e10
5 changed files with 127 additions and 4 deletions
@@ -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
@@ -2,8 +2,38 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
/// <summary>
/// Snapshot of an instance's debug state returned in response to a
/// <see cref="DebugSnapshotRequest"/> or <see cref="SubscribeDebugViewRequest"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Additive-only contract (M2.11):</b> <see cref="InstanceNotFound"/> is an
/// optional trailing parameter with a default of <see langword="false"/> so every
/// existing positional constructor call and every existing serialized wire frame
/// remains valid. Callers that receive a snapshot with
/// <c>InstanceNotFound = true</c> know the instance was unknown on the site and
/// should distinguish that from a deployed-but-empty instance
/// (<c>InstanceNotFound = false</c>, empty <see cref="AttributeValues"/> and
/// <see cref="AlarmStates"/>).
/// </para>
/// <para>
/// A new dedicated message type (<c>DebugViewInstanceNotFound</c>) was
/// considered but rejected: the ClusterClient / ClusterClientReceptionist
/// channel is typed on the request side and the bridge actor is already
/// pattern-matching on <c>DebugViewSnapshot</c> for the initial-snapshot TCS
/// in <c>DebugStreamService</c>. Introducing a second reply type would require
/// every consumer to handle an additional <c>Ask</c> result union — more change
/// for no additive-safety gain. The defaulted field is strictly additive and
/// keeps all call sites untouched.
/// </para>
/// </remarks>
public record DebugViewSnapshot(
string InstanceUniqueName,
IReadOnlyList<AttributeValueChanged> AttributeValues,
IReadOnlyList<AlarmStateChanged> 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);
@@ -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<DebugViewSnapshot>(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);
@@ -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<Commons.Messages.Streaming.AttributeValueChanged>(),
Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), DateTimeOffset.UtcNow));
Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), 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<Commons.Messages.Streaming.AttributeValueChanged>(),
Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), DateTimeOffset.UtcNow));
Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), DateTimeOffset.UtcNow,
InstanceNotFound: true));
}
}
@@ -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<DebugViewSnapshot>(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<DebugViewSnapshot>(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<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(800); // let InstanceActor spin up
actor.Tell(new DebugSnapshotRequest("EmptyPump", "snap-corr-2"));
var snapshot = ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
Assert.Equal("EmptyPump", snapshot.InstanceUniqueName);
Assert.False(snapshot.InstanceNotFound,
"A deployed (but empty) instance must NOT set InstanceNotFound.");
}
}