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:
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user