feat(comm): extract SiteCommandDispatcher; site serves commands over gRPC too (T1B.2)
Refactor SiteCommunicationActor's central→site routing table into one SiteCommandDispatcher — the single routing truth for the 28 migrated commands (IntegrationCallRequest, the dead 29th, stays on the actor and out of the dispatcher). The Akka actor and the new SiteCommandGrpcService both route through one dispatcher instance so the two transports can never drift on where a command goes. Server-side only: nothing central flips to gRPC yet (that is T1B.3); ClusterClient remains the live path. Decisions worth recording: - Targets preserved byte-for-byte. Lifecycle/OPC UA/query/route → the Deployment Manager singleton proxy; DeployArtifacts/EventLog/parked → their null-guarded handlers with the exact same "handler not available" replies; the parked handler stays NODE-LOCAL (per-node replicated-store owner), never the singleton proxy — pinned by a dispatcher test that asserts the target is the parked probe and NOT the dm proxy. - Sender preservation intact. The actor's command handlers became thin DispatchCommand delegations that still Forward (central Ask → reply routes straight back); the existing SiteCommunicationActorTests pass unchanged, which is the regression guard for that plumbing. UnsubscribeDebugView keeps its fire-and-forget shape: the actor Forwards, the gRPC service Tells + returns the synthetic UnsubscribeDebugViewAck so a unary RPC still answers. - Ack-before-Leave on failover. The dispatcher's PrepareFailover resolves the standby with a DRY-RUN (no leave) to build the ack, and hands back a deferred CommitLeave; the gRPC service returns the ack, then schedules the real Cluster.Leave — so a caller reaching the very node about to leave still gets its ack instead of a broken stream. The actor path keeps today's coupled resolve-and-leave (over ClusterClient the ack Tell only enqueues, so order is immaterial). Proven at both levels: a dispatcher test asserts the ack is built before CommitLeave runs, and a TestServer test asserts the recorded seam order is resolve-then-leave. - ControlPlaneAuthInterceptor gates SiteCommandService by EXTENDING DefaultGatedPrefixes (descriptor-derived), not by adding a constructor — the one-public-ctor invariant and its test stay green. Tests: SiteCommandDispatcherTests (28-command routing incl. parked node-locality and both failover paths) and SiteCommandGrpcService TestServer tests (auth, readiness→Unavailable, one command per oneof group, failover ordering). Full solution build 0/0; Communication.Tests 574 and Host.Tests 377 green. No active <Protobuf> item.
This commit is contained in:
@@ -317,7 +317,7 @@ Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate.
|
||||
|
||||
**Phase 1B — site command plane** (worktree, `feat/grpc-sitecommand`)
|
||||
- [x] T1B.1 `site_command.proto` (6 oneof RPCs / 28 commands) + `SiteCommandDtoMapper` + round-trip golden tests (all 28 commands + 22 reply shapes + 18 nested types; reflection-driven coverage guard over the mapper surface and the generated oneof descriptors)
|
||||
- [x] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local)
|
||||
- [x] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local; failover ack-before-Leave via dry-run resolve + deferred `CommitLeave`; interceptor `DefaultGatedPrefixes` extended to `SiteCommandService`)
|
||||
- [x] T1B.3 `ISiteCommandTransport` in `CentralCommunicationActor` (Akka extract + Grpc impl), `SitePairChannelProvider` (Site entity Grpc columns + DB refresh loop), `SiteTransport` flag default `Akka`
|
||||
- [x] T1B.4 Tests: dispatcher routing ×28, actor envelope/reply plumbing, TestServer service tests, existing Communication suites green
|
||||
- [ ] 1B DoD: rig central on `Grpc` for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using Akka.Actor;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// The single routing truth for the 28 migrated central→site commands. Given one
|
||||
/// of those command records it decides which local target answers it —
|
||||
/// the Deployment Manager singleton proxy, the artifact/event-log/parked-message
|
||||
/// handlers, or the node-local failover path — preserving EXACTLY the targets and
|
||||
/// null-guard semantics <see cref="SiteCommunicationActor"/> used when this routing
|
||||
/// lived inline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two transports call this one unit: the Akka <see cref="SiteCommunicationActor"/>
|
||||
/// (ClusterClient) and the new <c>SiteCommandGrpcService</c> (gRPC). Centralising the
|
||||
/// table means the two can never drift on where a command goes. Each transport keeps
|
||||
/// its own send mechanics — the actor <c>Forward</c>s (preserving the Ask sender), the
|
||||
/// gRPC service <c>Ask</c>s and encodes the reply — but both read the same
|
||||
/// <see cref="Route"/> here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The parked-message handler stays node-local on purpose.</b> A parked retry or
|
||||
/// discard must run on the node that holds the replicated store row, so parked
|
||||
/// commands route to the per-node <c>_parkedMessageHandler</c> — never onto the
|
||||
/// singleton proxy (design §7.3). This is the same target the actor used; the
|
||||
/// extraction does not "fix" it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Excluded by design:</b> <c>IntegrationCallRequest</c> — the 29th command, dead at
|
||||
/// both ends. It never enters this dispatcher; the actor keeps its own vestigial handler
|
||||
/// for it (28 of 29 migrate).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SiteCommandDispatcher
|
||||
{
|
||||
/// <summary>How a resolved command is delivered to its target.</summary>
|
||||
public enum RouteDisposition
|
||||
{
|
||||
/// <summary>Forward/Ask <see cref="Route.Target"/> and route its reply back.</summary>
|
||||
Forward,
|
||||
|
||||
/// <summary>
|
||||
/// Tell <see cref="Route.Target"/> and answer immediately with
|
||||
/// <see cref="Route.Reply"/> — the fire-and-forget path (only
|
||||
/// <see cref="UnsubscribeDebugViewRequest"/>, which the downstream never acks).
|
||||
/// The actor <c>Forward</c>s it and sends nothing back; the gRPC transport Tells and
|
||||
/// returns the synthetic ack so a unary RPC still answers.
|
||||
/// </summary>
|
||||
TellFireAndForget,
|
||||
|
||||
/// <summary>
|
||||
/// No target is available (a null-guarded handler is unregistered); answer with the
|
||||
/// synthetic <see cref="Route.Reply"/> — the "handler not available" reply the actor
|
||||
/// produced inline.
|
||||
/// </summary>
|
||||
ImmediateReply
|
||||
}
|
||||
|
||||
/// <summary>A resolved routing decision for one command.</summary>
|
||||
/// <param name="Disposition">How the command is delivered.</param>
|
||||
/// <param name="Target">The target actor for <see cref="RouteDisposition.Forward"/>/<see cref="RouteDisposition.TellFireAndForget"/>; <c>null</c> for an immediate reply.</param>
|
||||
/// <param name="Reply">The synthetic reply for <see cref="RouteDisposition.ImmediateReply"/>/<see cref="RouteDisposition.TellFireAndForget"/>; <c>null</c> for a forward.</param>
|
||||
public readonly record struct Route(RouteDisposition Disposition, IActorRef? Target, object? Reply);
|
||||
|
||||
/// <summary>The outcome of preparing a failover: the ack to send now, plus the leave to run after.</summary>
|
||||
/// <param name="Ack">The ack the caller must send back before the node leaves.</param>
|
||||
/// <param name="CommitLeave">
|
||||
/// When non-<c>null</c> (accepted only), invoking it performs the real graceful
|
||||
/// <c>Cluster.Leave</c>. It is deliberately deferred so the transport can flush the
|
||||
/// <see cref="Ack"/> first — a caller reaching the very node that is about to leave still
|
||||
/// receives the ack rather than a broken stream.
|
||||
/// </param>
|
||||
public readonly record struct FailoverOutcome(SiteFailoverAck Ack, Action? CommitLeave);
|
||||
|
||||
private readonly string _siteId;
|
||||
private readonly IActorRef _deploymentManagerProxy;
|
||||
|
||||
// (role, dryRun) -> leaving node address, or null when there is no standby.
|
||||
// dryRun:true resolves the target WITHOUT leaving; dryRun:false performs the Leave.
|
||||
private readonly Func<string, bool, string?> _resolveFailover;
|
||||
|
||||
// Registered at runtime via the actor's RegisterLocalHandler flow. Written on the actor
|
||||
// thread, read by both the actor and the gRPC service (a Kestrel thread), so volatile.
|
||||
private volatile IActorRef? _eventLogHandler;
|
||||
private volatile IActorRef? _parkedMessageHandler;
|
||||
private volatile IActorRef? _artifactHandler;
|
||||
|
||||
/// <summary>Creates the dispatcher.</summary>
|
||||
/// <param name="siteId">This site's identifier, stamped into synthetic replies and matched by the failover guard.</param>
|
||||
/// <param name="deploymentManagerProxy">The local Deployment Manager singleton proxy — the target for all lifecycle/OPC UA/query/route commands.</param>
|
||||
/// <param name="resolveFailover">
|
||||
/// Resolves (and, when <c>dryRun</c> is false, performs) the graceful leave of the oldest
|
||||
/// Up member in a role scope. Injected so tests need no real cluster.
|
||||
/// </param>
|
||||
public SiteCommandDispatcher(
|
||||
string siteId,
|
||||
IActorRef deploymentManagerProxy,
|
||||
Func<string, bool, string?> resolveFailover)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(siteId);
|
||||
ArgumentNullException.ThrowIfNull(deploymentManagerProxy);
|
||||
ArgumentNullException.ThrowIfNull(resolveFailover);
|
||||
|
||||
_siteId = siteId;
|
||||
_deploymentManagerProxy = deploymentManagerProxy;
|
||||
_resolveFailover = resolveFailover;
|
||||
}
|
||||
|
||||
/// <summary>Registers the site event-log query handler (a cluster singleton proxy).</summary>
|
||||
/// <param name="handler">The event-log handler.</param>
|
||||
public void RegisterEventLogHandler(IActorRef handler) => _eventLogHandler = handler;
|
||||
|
||||
/// <summary>Registers the node-local parked-message handler (the replicated-store owner on this node).</summary>
|
||||
/// <param name="handler">The parked-message handler.</param>
|
||||
public void RegisterParkedMessageHandler(IActorRef handler) => _parkedMessageHandler = handler;
|
||||
|
||||
/// <summary>Registers the artifact-deployment handler.</summary>
|
||||
/// <param name="handler">The artifact handler.</param>
|
||||
public void RegisterArtifactHandler(IActorRef handler) => _artifactHandler = handler;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves how one of the 27 non-failover commands is delivered. <see cref="TriggerSiteFailover"/>
|
||||
/// is handled separately via <see cref="PrepareFailover"/>/<see cref="HandleFailover"/> because its
|
||||
/// leave is deferred; passing it here throws.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to route.</param>
|
||||
/// <returns>The routing decision.</returns>
|
||||
/// <exception cref="ArgumentException">The command is not a migrated site command (or is failover).</exception>
|
||||
public Route ResolveRoute(object command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
return command switch
|
||||
{
|
||||
// ── Deployment + instance lifecycle → Deployment Manager singleton proxy ──
|
||||
RefreshDeploymentCommand => ToProxy(),
|
||||
EnableInstanceCommand => ToProxy(),
|
||||
DisableInstanceCommand => ToProxy(),
|
||||
DeleteInstanceCommand => ToProxy(),
|
||||
DeploymentStateQueryRequest => ToProxy(),
|
||||
|
||||
// ── Artifact deployment → artifact handler (null-guarded) ──
|
||||
DeployArtifactsCommand c => _artifactHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new ArtifactDeploymentResponse(
|
||||
c.DeploymentId, _siteId, false, "Artifact handler not available", DateTimeOffset.UtcNow)),
|
||||
|
||||
// ── Interactive OPC UA / MxGateway → Deployment Manager singleton proxy ──
|
||||
// The singleton always lands on the active node, which owns the live sessions.
|
||||
BrowseNodeCommand => ToProxy(),
|
||||
SearchAddressSpaceCommand => ToProxy(),
|
||||
ReadTagValuesCommand => ToProxy(),
|
||||
VerifyEndpointCommand => ToProxy(),
|
||||
TrustServerCertCommand => ToProxy(),
|
||||
ListServerCertsCommand => ToProxy(),
|
||||
RemoveServerCertCommand => ToProxy(),
|
||||
WriteTagRequest => ToProxy(),
|
||||
|
||||
// ── Remote queries: event log (null-guarded) + debug view (proxy) ──
|
||||
EventLogQueryRequest r => _eventLogHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new EventLogQueryResponse(
|
||||
r.CorrelationId, _siteId, [], null, false, false,
|
||||
"Event log handler not available", DateTimeOffset.UtcNow)),
|
||||
DebugSnapshotRequest => ToProxy(),
|
||||
SubscribeDebugViewRequest => ToProxy(),
|
||||
// Fire-and-forget: the Deployment Manager never acks an unsubscribe, so the gRPC
|
||||
// transport Tells it and returns the synthetic ack; the actor just Forwards.
|
||||
UnsubscribeDebugViewRequest => new Route(
|
||||
RouteDisposition.TellFireAndForget, _deploymentManagerProxy, UnsubscribeDebugViewAck.Instance),
|
||||
|
||||
// ── Parked store-and-forward actions → node-local parked handler (null-guarded) ──
|
||||
ParkedMessageQueryRequest r => _parkedMessageHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new ParkedMessageQueryResponse(
|
||||
r.CorrelationId, _siteId, [], 0, r.PageNumber, r.PageSize, false,
|
||||
"Parked message handler not available", DateTimeOffset.UtcNow)),
|
||||
ParkedMessageRetryRequest r => _parkedMessageHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new ParkedMessageRetryResponse(
|
||||
r.CorrelationId, false, "Parked message handler not available")),
|
||||
ParkedMessageDiscardRequest r => _parkedMessageHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new ParkedMessageDiscardResponse(
|
||||
r.CorrelationId, false, "Parked message handler not available")),
|
||||
RetryParkedOperation r => _parkedMessageHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new ParkedOperationActionAck(
|
||||
r.CorrelationId, Applied: false, "Parked message handler not available")),
|
||||
DiscardParkedOperation r => _parkedMessageHandler is { } h
|
||||
? Forwarded(h)
|
||||
: Immediate(new ParkedOperationActionAck(
|
||||
r.CorrelationId, Applied: false, "Parked message handler not available")),
|
||||
|
||||
// ── Inbound-API Route.To() relays → Deployment Manager singleton proxy ──
|
||||
RouteToCallRequest => ToProxy(),
|
||||
RouteToGetAttributesRequest => ToProxy(),
|
||||
RouteToSetAttributesRequest => ToProxy(),
|
||||
RouteToWaitForAttributeRequest => ToProxy(),
|
||||
|
||||
TriggerSiteFailover => throw new ArgumentException(
|
||||
"TriggerSiteFailover is handled by PrepareFailover/HandleFailover, not ResolveRoute.",
|
||||
nameof(command)),
|
||||
|
||||
_ => throw new ArgumentException(
|
||||
$"'{command.GetType().Name}' is not a migrated site command.", nameof(command))
|
||||
};
|
||||
|
||||
Route ToProxy() => Forwarded(_deploymentManagerProxy);
|
||||
}
|
||||
|
||||
private static Route Forwarded(IActorRef target) => new(RouteDisposition.Forward, target, null);
|
||||
|
||||
private static Route Immediate(object reply) => new(RouteDisposition.ImmediateReply, null, reply);
|
||||
|
||||
/// <summary>
|
||||
/// The actor's failover path: resolve the standby AND issue the leave in one step (today's
|
||||
/// coupled behaviour over ClusterClient, where <c>Tell</c> merely enqueues the ack so leave
|
||||
/// order is immaterial), then return the ack.
|
||||
/// </summary>
|
||||
/// <param name="msg">The failover command.</param>
|
||||
/// <returns>The ack to send back.</returns>
|
||||
public SiteFailoverAck HandleFailover(TriggerSiteFailover msg)
|
||||
=> FailoverCore(msg, commitLeaveImmediately: true).Ack;
|
||||
|
||||
/// <summary>
|
||||
/// The gRPC failover path: resolve the standby WITHOUT leaving, build the ack, and hand back a
|
||||
/// deferred <see cref="FailoverOutcome.CommitLeave"/>. The caller must send the ack before
|
||||
/// invoking the leave — otherwise a caller reaching the leaving node sees a broken stream
|
||||
/// instead of its ack (the ack-before-Leave rule).
|
||||
/// </summary>
|
||||
/// <param name="msg">The failover command.</param>
|
||||
/// <returns>The ack and the deferred leave (leave is <c>null</c> when refused).</returns>
|
||||
public FailoverOutcome PrepareFailover(TriggerSiteFailover msg)
|
||||
=> FailoverCore(msg, commitLeaveImmediately: false);
|
||||
|
||||
private FailoverOutcome FailoverCore(TriggerSiteFailover msg, bool commitLeaveImmediately)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(msg);
|
||||
|
||||
// A misrouted command must be refused, not silently acted on — acting would fail over a
|
||||
// site the operator never selected. Checked before resolving so the resolver is untouched.
|
||||
if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal))
|
||||
{
|
||||
return new FailoverOutcome(
|
||||
new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||
ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'."),
|
||||
CommitLeave: null);
|
||||
}
|
||||
|
||||
// Site singletons are scoped to the site-specific role, so failover must target that role.
|
||||
var role = $"site-{_siteId}";
|
||||
try
|
||||
{
|
||||
if (commitLeaveImmediately)
|
||||
{
|
||||
var target = _resolveFailover(role, false);
|
||||
if (target is null)
|
||||
{
|
||||
return new FailoverOutcome(NoPeerAck(msg), CommitLeave: null);
|
||||
}
|
||||
|
||||
return new FailoverOutcome(
|
||||
new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null),
|
||||
CommitLeave: null);
|
||||
}
|
||||
|
||||
var resolved = _resolveFailover(role, true);
|
||||
if (resolved is null)
|
||||
{
|
||||
return new FailoverOutcome(NoPeerAck(msg), CommitLeave: null);
|
||||
}
|
||||
|
||||
return new FailoverOutcome(
|
||||
new SiteFailoverAck(msg.CorrelationId, Accepted: true, resolved, ErrorMessage: null),
|
||||
CommitLeave: () => _resolveFailover(role, false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A fault must be reported to the operator, never thrown into supervision (over
|
||||
// ClusterClient) or surfaced as a broken stream (over gRPC).
|
||||
return new FailoverOutcome(
|
||||
new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message),
|
||||
CommitLeave: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static SiteFailoverAck NoPeerAck(TriggerSiteFailover msg) => new(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||
ErrorMessage: "No standby available — failing over a lone node would be an outage.");
|
||||
}
|
||||
@@ -36,13 +36,20 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// do not need a real cluster.
|
||||
/// </summary>
|
||||
private readonly Func<bool> _isActiveCheck;
|
||||
private readonly Func<string, string?> _failOverRole;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the local Deployment Manager singleton proxy.
|
||||
/// </summary>
|
||||
private readonly IActorRef _deploymentManagerProxy;
|
||||
|
||||
/// <summary>
|
||||
/// The single routing truth for the 28 migrated central→site commands, shared with
|
||||
/// the gRPC <c>SiteCommandGrpcService</c> so the two transports cannot drift. In
|
||||
/// production it is created by the Host and passed in (so the gRPC server holds the
|
||||
/// same instance); when unset (tests) the actor builds its own from the failover seam.
|
||||
/// </summary>
|
||||
private readonly SiteCommandDispatcher _dispatcher;
|
||||
|
||||
/// <summary>
|
||||
/// The site→central transport. Finalized in <see cref="PreStart"/> to the injected instance,
|
||||
/// or a default <see cref="AkkaCentralTransport"/> (ClusterClient) when none is supplied — so
|
||||
@@ -54,13 +61,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
private readonly ICentralTransport? _injectedTransport;
|
||||
|
||||
/// <summary>
|
||||
/// Local actor references for routing specific message patterns.
|
||||
/// Populated via registration messages.
|
||||
/// Handler for the vestigial <see cref="IntegrationCallRequest"/> — the one command NOT
|
||||
/// migrated to the dispatcher (dead at both ends), so it still routes on the actor.
|
||||
/// </summary>
|
||||
private IActorRef? _eventLogHandler;
|
||||
private IActorRef? _parkedMessageHandler;
|
||||
private IActorRef? _integrationHandler;
|
||||
private IActorRef? _artifactHandler;
|
||||
|
||||
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
@@ -82,24 +86,41 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// Host injects a <see cref="Grpc.GrpcCentralTransport"/> when
|
||||
/// <c>ScadaBridge:Communication:CentralTransport</c> is <c>Grpc</c>.
|
||||
/// </param>
|
||||
/// <param name="dispatcher">
|
||||
/// The shared <see cref="SiteCommandDispatcher"/> (production: created by the Host and also
|
||||
/// handed to the gRPC command service, so both transports route through one instance).
|
||||
/// <c>null</c> makes the actor build its own from <paramref name="failOverRole"/> — the shape
|
||||
/// the existing tests use.
|
||||
/// </param>
|
||||
public SiteCommunicationActor(
|
||||
string siteId,
|
||||
CommunicationOptions options,
|
||||
IActorRef deploymentManagerProxy,
|
||||
Func<bool>? isActiveCheck = null,
|
||||
Func<string, string?>? failOverRole = null,
|
||||
ICentralTransport? transport = null)
|
||||
ICentralTransport? transport = null,
|
||||
SiteCommandDispatcher? dispatcher = null)
|
||||
{
|
||||
_siteId = siteId;
|
||||
_options = options;
|
||||
_deploymentManagerProxy = deploymentManagerProxy;
|
||||
_isActiveCheck = isActiveCheck ?? DefaultIsActiveCheck;
|
||||
_failOverRole = failOverRole ?? DefaultFailOverRole;
|
||||
_injectedTransport = transport;
|
||||
// Finalized in PreStart (where _log is usable for the default transport); assigned here
|
||||
// too so the field is definitely-assigned for the constructor's Receive closures.
|
||||
_transport = transport!;
|
||||
|
||||
// When no shared dispatcher is supplied, build one over the same failover seam the
|
||||
// actor used before extraction: an injected Func (tests) that both resolves and leaves,
|
||||
// or the shared ClusterFailoverCoordinator. The actor only ever commits the leave
|
||||
// immediately (dryRun:false), so an injected coupled Func fits unchanged.
|
||||
var system = Context.System;
|
||||
Func<string, bool, string?> resolveFailover = failOverRole is not null
|
||||
? (role, _) => failOverRole(role)
|
||||
: (role, dryRun) =>
|
||||
ClusterState.ClusterFailoverCoordinator.FailOverOldest(system, role, dryRun)?.ToString();
|
||||
_dispatcher = dispatcher ?? new SiteCommandDispatcher(siteId, deploymentManagerProxy, resolveFailover);
|
||||
|
||||
// Registration. Feeding the ClusterClient into the transport is a no-op unless the
|
||||
// default/Akka transport is in use — the gRPC transport dials configured endpoints and
|
||||
// never receives this message (the Host does not create a ClusterClient for it).
|
||||
@@ -109,37 +130,44 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
});
|
||||
Receive<RegisterLocalHandler>(HandleRegisterLocalHandler);
|
||||
|
||||
// Pattern 1: Instance Deployment — forward to Deployment Manager
|
||||
Receive<RefreshDeploymentCommand>(msg =>
|
||||
{
|
||||
_log.Debug("Routing RefreshDeploymentCommand for {0} to DeploymentManager", msg.InstanceUniqueName);
|
||||
_deploymentManagerProxy.Forward(msg);
|
||||
});
|
||||
// ── The 27 migrated central→site commands (28th is failover, below) all route
|
||||
// through the shared SiteCommandDispatcher — the single routing truth also used by
|
||||
// the gRPC SiteCommandGrpcService. The actor's job per command is unchanged: Forward
|
||||
// to the resolved target (preserving the central Ask sender so replies route straight
|
||||
// back to the waiting Ask), or Tell the caller the dispatcher's synthetic reply when a
|
||||
// null-guarded handler is unregistered. See SiteCommandDispatcher for the target of
|
||||
// each command and why the parked handler stays node-local.
|
||||
Receive<RefreshDeploymentCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<DisableInstanceCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<EnableInstanceCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<DeleteInstanceCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<DeploymentStateQueryRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<DeployArtifactsCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<SubscribeDebugViewRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<UnsubscribeDebugViewRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<DebugSnapshotRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<RouteToCallRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<RouteToGetAttributesRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<RouteToSetAttributesRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<RouteToWaitForAttributeRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<BrowseNodeCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<ReadTagValuesCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<SearchAddressSpaceCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<Commons.Messages.DataConnection.WriteTagRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<VerifyEndpointCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<TrustServerCertCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<ListServerCertsCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<RemoveServerCertCommand>(cmd => DispatchCommand(cmd));
|
||||
Receive<EventLogQueryRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<ParkedMessageQueryRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<ParkedMessageRetryRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<ParkedMessageDiscardRequest>(cmd => DispatchCommand(cmd));
|
||||
Receive<RetryParkedOperation>(cmd => DispatchCommand(cmd));
|
||||
Receive<DiscardParkedOperation>(cmd => DispatchCommand(cmd));
|
||||
|
||||
// Pattern 2: Lifecycle — forward to Deployment Manager
|
||||
Receive<DisableInstanceCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<EnableInstanceCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<DeleteInstanceCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Query-the-site-before-redeploy — forward to
|
||||
// the Deployment Manager, which owns the deployed-config store and
|
||||
// answers with the instance's currently-applied deployment identity.
|
||||
Receive<DeploymentStateQueryRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Pattern 3: Artifact Deployment — forward to artifact handler if registered
|
||||
Receive<DeployArtifactsCommand>(msg =>
|
||||
{
|
||||
if (_artifactHandler != null)
|
||||
_artifactHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
_log.Warning("No artifact handler registered, replying with failure");
|
||||
Sender.Tell(new ArtifactDeploymentResponse(
|
||||
msg.DeploymentId, _siteId, false, "Artifact handler not available", DateTimeOffset.UtcNow));
|
||||
}
|
||||
});
|
||||
|
||||
// Pattern 4: Integration Routing — forward to integration handler
|
||||
// Integration Routing — the 29th command, NOT migrated to the dispatcher (dead at
|
||||
// both ends; no production code registers the handler). Kept on the actor so the
|
||||
// dispatcher's command surface stays the 28 that actually migrate.
|
||||
Receive<IntegrationCallRequest>(msg =>
|
||||
{
|
||||
if (_integrationHandler != null)
|
||||
@@ -151,143 +179,15 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
});
|
||||
|
||||
// Pattern 5: Debug View — forward to Deployment Manager (which routes to Instance Actor)
|
||||
Receive<SubscribeDebugViewRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<UnsubscribeDebugViewRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager
|
||||
Receive<DebugSnapshotRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Inbound API Route.To() — forward to Deployment Manager for instance routing
|
||||
Receive<RouteToCallRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<RouteToGetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<RouteToSetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<RouteToWaitForAttributeRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// OPC UA Tag Browser (interactive design-time query) — forward to the
|
||||
// Deployment Manager singleton, which always lands on the active site
|
||||
// node. Routing to the site-local /user/dcl-manager directly is wrong
|
||||
// because the standby node has a dcl-manager too, but its
|
||||
// DataConnectionActor children (which own the live OPC UA sessions)
|
||||
// only exist on the singleton's node. The singleton then re-forwards
|
||||
// to its own /user/dcl-manager, which DOES have the connection.
|
||||
Receive<BrowseNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Test Bindings (interactive design-time read) — same routing rationale
|
||||
// as BrowseNodeCommand above: the singleton always lands on the
|
||||
// active site node, which is the node that owns the DataConnectionActor
|
||||
// children holding the live OPC UA sessions.
|
||||
Receive<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// OPC UA tag-picker address-space search and secured-write execute
|
||||
// — same singleton routing rationale as BrowseNodeCommand above: the
|
||||
// DataConnectionActor children that own the live OPC UA sessions exist only
|
||||
// on the singleton's (active) node, so these must hop through the Deployment
|
||||
// Manager proxy too. Without these forwards the commands dead-letter and the
|
||||
// central Ask times out. Forward preserves the central Ask sender so the
|
||||
// result routes straight back to the waiting Ask.
|
||||
Receive<SearchAddressSpaceCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<Commons.Messages.DataConnection.WriteTagRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// OPC UA endpoint Verify — probes a (possibly unsaved) endpoint config
|
||||
// WITHOUT persisting it. The Deployment Manager singleton's dcl-manager runs
|
||||
// the probe directly (no existing connection required), so — like the
|
||||
// commands above — Verify routes through the singleton's active node.
|
||||
Receive<VerifyEndpointCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// OPC UA server-certificate trust management — forward to the
|
||||
// Deployment Manager singleton, which owns the cross-node trust broadcast.
|
||||
// The trusted-peer PKI store is node-wide per site node, so a trust/remove
|
||||
// decision must reach BOTH nodes' CertStoreActor; the singleton broadcasts
|
||||
// to every site node (list answers from the singleton's own node). The
|
||||
// singleton always lands on the active node, the same routing rationale as
|
||||
// BrowseNodeCommand above. Forward preserves the central Ask sender so the
|
||||
// CertTrustResult routes straight back to the waiting Ask.
|
||||
Receive<TrustServerCertCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<ListServerCertsCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<RemoveServerCertCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Pattern 7: Remote Queries
|
||||
Receive<EventLogQueryRequest>(msg =>
|
||||
{
|
||||
if (_eventLogHandler != null)
|
||||
_eventLogHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new EventLogQueryResponse(
|
||||
msg.CorrelationId, _siteId, [], null, false, false,
|
||||
"Event log handler not available", DateTimeOffset.UtcNow));
|
||||
}
|
||||
});
|
||||
|
||||
Receive<ParkedMessageQueryRequest>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedMessageQueryResponse(
|
||||
msg.CorrelationId, _siteId, [], 0, msg.PageNumber, msg.PageSize, false,
|
||||
"Parked message handler not available", DateTimeOffset.UtcNow));
|
||||
}
|
||||
});
|
||||
|
||||
Receive<ParkedMessageRetryRequest>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedMessageRetryResponse(
|
||||
msg.CorrelationId, false, "Parked message handler not available"));
|
||||
}
|
||||
});
|
||||
|
||||
Receive<ParkedMessageDiscardRequest>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedMessageDiscardResponse(
|
||||
msg.CorrelationId, false, "Parked message handler not available"));
|
||||
}
|
||||
});
|
||||
|
||||
// Central→site Retry/Discard relay for parked cached
|
||||
// operations. SiteCallAuditActor relays these over the command/control
|
||||
// channel; the parked-message handler executes them against the local
|
||||
// S&F buffer and replies a ParkedOperationActionAck that routes back to
|
||||
// the relaying SiteCallAuditActor's Ask.
|
||||
Receive<RetryParkedOperation>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedOperationActionAck(
|
||||
msg.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||
}
|
||||
});
|
||||
|
||||
Receive<DiscardParkedOperation>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedOperationActionAck(
|
||||
msg.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||
}
|
||||
});
|
||||
|
||||
// Central→site manual failover relay. Central and the site are separate clusters,
|
||||
// so central can only ask — this node performs the graceful Leave locally, scoped to
|
||||
// the SITE-SPECIFIC role, because that is what site singletons (the Deployment
|
||||
// Manager) are placed on. Either node may receive this (the actor is per-node, not a
|
||||
// singleton, and contact rotation picks whichever answers); the target is resolved
|
||||
// from cluster state, not from who received the message.
|
||||
Receive<TriggerSiteFailover>(HandleTriggerSiteFailover);
|
||||
// from cluster state, not from who received the message. Over ClusterClient the ack
|
||||
// Tell merely enqueues, so the dispatcher resolves-and-leaves in one step (the gRPC
|
||||
// transport defers the leave to keep ack-before-Leave — see PrepareFailover).
|
||||
Receive<TriggerSiteFailover>(msg => Sender.Tell(_dispatcher.HandleFailover(msg)));
|
||||
|
||||
// The seven site→central sends now delegate to the injected transport (ClusterClient by
|
||||
// default, gRPC when configured). Each handler captures the current Sender as the reply
|
||||
@@ -352,21 +252,47 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
_options.ApplicationHeartbeatInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a resolved command route within the actor: Forward to the target (preserving the
|
||||
/// central Ask sender so the reply routes straight back to the waiting Ask), or — when a
|
||||
/// null-guarded handler is unregistered — Tell the caller the dispatcher's synthetic reply.
|
||||
/// The fire-and-forget disposition (UnsubscribeDebugView) is a plain Forward here, exactly as
|
||||
/// before: over ClusterClient the site never acked it, so the synthetic ack is a gRPC-only
|
||||
/// concern.
|
||||
/// </summary>
|
||||
/// <param name="command">The migrated central→site command to route.</param>
|
||||
private void DispatchCommand(object command)
|
||||
{
|
||||
var route = _dispatcher.ResolveRoute(command);
|
||||
switch (route.Disposition)
|
||||
{
|
||||
case SiteCommandDispatcher.RouteDisposition.ImmediateReply:
|
||||
Sender.Tell(route.Reply!);
|
||||
break;
|
||||
default:
|
||||
// Forward and TellFireAndForget both Forward on the actor path.
|
||||
route.Target!.Forward(command);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRegisterLocalHandler(RegisterLocalHandler msg)
|
||||
{
|
||||
// The migrated handlers live on the shared dispatcher so the gRPC command service sees the
|
||||
// same registrations. Integration is the one command kept on the actor (see the receive).
|
||||
switch (msg.HandlerType)
|
||||
{
|
||||
case LocalHandlerType.EventLog:
|
||||
_eventLogHandler = msg.Handler;
|
||||
_dispatcher.RegisterEventLogHandler(msg.Handler);
|
||||
break;
|
||||
case LocalHandlerType.ParkedMessages:
|
||||
_parkedMessageHandler = msg.Handler;
|
||||
_dispatcher.RegisterParkedMessageHandler(msg.Handler);
|
||||
break;
|
||||
case LocalHandlerType.Integration:
|
||||
_integrationHandler = msg.Handler;
|
||||
break;
|
||||
case LocalHandlerType.Artifacts:
|
||||
_artifactHandler = msg.Handler;
|
||||
_dispatcher.RegisterArtifactHandler(msg.Handler);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -431,69 +357,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
private bool DefaultIsActiveCheck() =>
|
||||
ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(Cluster.Get(Context.System));
|
||||
|
||||
/// <summary>
|
||||
/// Handles a central-initiated site failover. Refuses a command addressed to a different
|
||||
/// site (a misroute must never silently fail over a site the operator did not select) and
|
||||
/// refuses when the site pair has no peer to take over. The ack is sent BEFORE the Leave
|
||||
/// takes effect on the wire, so it still reaches central even when this node is the one
|
||||
/// leaving.
|
||||
/// </summary>
|
||||
private void HandleTriggerSiteFailover(TriggerSiteFailover msg)
|
||||
{
|
||||
if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal))
|
||||
{
|
||||
_log.Warning(
|
||||
"Refusing TriggerSiteFailover addressed to site {Requested}; this node serves {Actual}",
|
||||
msg.SiteId, _siteId);
|
||||
Sender.Tell(new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||
ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'."));
|
||||
return;
|
||||
}
|
||||
|
||||
var role = $"site-{_siteId}";
|
||||
try
|
||||
{
|
||||
var target = _failOverRole(role);
|
||||
if (target is null)
|
||||
{
|
||||
_log.Warning(
|
||||
"Refusing TriggerSiteFailover for {SiteId}: fewer than 2 Up members in role {Role}, "
|
||||
+ "so there is no standby to take over", _siteId, role);
|
||||
Sender.Tell(new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||
ErrorMessage: "No standby available — failing over a lone node would be an outage."));
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Warning(
|
||||
"Manual failover requested by central for site {SiteId}: {Target} is leaving the "
|
||||
+ "site cluster gracefully; its singletons hand over to the standby.", _siteId, target);
|
||||
Sender.Tell(new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A fault here must be reported to the operator, not thrown into supervision —
|
||||
// restarting the communication actor would drop central's Ask into a timeout and
|
||||
// lose the reason.
|
||||
_log.Error(ex, "TriggerSiteFailover for {SiteId} faulted", _siteId);
|
||||
Sender.Tell(new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production failover action: gracefully Leave the oldest Up member carrying
|
||||
/// <paramref name="role"/>, via the shared <see cref="ClusterState.ClusterFailoverCoordinator"/>
|
||||
/// so the central and site paths cannot drift. Injected in tests for the same reason
|
||||
/// <see cref="DefaultIsActiveCheck"/> is — a real Leave needs Akka.Cluster in the
|
||||
/// ActorSystem, which the TestKit system does not load.
|
||||
/// </summary>
|
||||
/// <param name="role">Site-specific role scope.</param>
|
||||
/// <returns>Address of the leaving node, or null when there is no peer.</returns>
|
||||
private string? DefaultFailOverRole(string role) =>
|
||||
ClusterState.ClusterFailoverCoordinator.FailOverOldest(Context.System, role)?.ToString();
|
||||
|
||||
// ── Internal messages ──
|
||||
|
||||
internal record SendHeartbeat;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using Akka.Actor;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using GrpcStatus = Grpc.Core.Status;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC front door for the central→site command plane: decodes a proto command (via
|
||||
/// <see cref="SiteCommandDtoMapper"/>), routes it through the ONE
|
||||
/// <see cref="SiteCommandDispatcher"/> the Akka <c>SiteCommunicationActor</c> also uses, and
|
||||
/// encodes the reply. One routing truth, two transports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mapped in the site branch next to <c>SiteStreamGrpcServer</c>, behind the same
|
||||
/// <c>ControlPlaneAuthInterceptor</c> PSK gate and the same readiness convention: calls are
|
||||
/// rejected with <see cref="StatusCode.Unavailable"/> until <see cref="SetReady"/> is called
|
||||
/// once the site actor system is up (mirrors <c>SiteStreamGrpcServer.SetReady</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Coexistence — server-side only.</b> This makes the site ALSO listen on gRPC for commands;
|
||||
/// nothing central flips to gRPC here (that is T1B.3). Central still dials sites via ClusterClient.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fire-and-forget.</b> The Akka path never acks <c>UnsubscribeDebugView</c>, but a unary RPC
|
||||
/// must answer, so the dispatcher marks it <see cref="SiteCommandDispatcher.RouteDisposition.TellFireAndForget"/>
|
||||
/// and this service Tells the target then returns the synthetic ack.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ack-before-Leave.</b> <c>TriggerFailover</c> resolves the standby WITHOUT leaving
|
||||
/// (<see cref="SiteCommandDispatcher.PrepareFailover"/>), returns the ack, and only THEN schedules
|
||||
/// the real <c>Cluster.Leave</c> — so a caller reaching the very node that is about to leave still
|
||||
/// receives its ack instead of a broken stream.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SiteCommandGrpcService : SiteCommandService.SiteCommandServiceBase
|
||||
{
|
||||
// A local Ask has no client deadline of its own; fall back to a generous ceiling when the
|
||||
// caller set none (a deadline-less client is a test or an internal caller). When the caller
|
||||
// DID set a gRPC deadline, honour the remaining time instead.
|
||||
private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly ILogger<SiteCommandGrpcService> _logger;
|
||||
private readonly Action<Action> _leaveScheduler;
|
||||
|
||||
// Set once by SetReady after the site actor system and dispatcher exist. Read on Kestrel
|
||||
// threads, written on the host bring-up thread — volatile.
|
||||
private volatile SiteCommandDispatcher? _dispatcher;
|
||||
private volatile bool _ready;
|
||||
|
||||
/// <summary>DI constructor.</summary>
|
||||
/// <param name="logger">Logger for denial/dispatch diagnostics.</param>
|
||||
public SiteCommandGrpcService(ILogger<SiteCommandGrpcService> logger)
|
||||
: this(logger, DeferLeaveUntilAfterReply)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test constructor letting a test observe when the deferred leave runs. Internal so DI sees a
|
||||
/// single public constructor.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="leaveScheduler">Runs the deferred <c>Cluster.Leave</c> after the ack is returned.</param>
|
||||
internal SiteCommandGrpcService(ILogger<SiteCommandGrpcService> logger, Action<Action> leaveScheduler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(leaveScheduler);
|
||||
_logger = logger;
|
||||
_leaveScheduler = leaveScheduler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the service ready and injects the shared routing table. Called once the site actor
|
||||
/// system, the Deployment Manager singleton, and the local handlers are up — the same point
|
||||
/// <c>SiteStreamGrpcServer.SetReady</c> is called.
|
||||
/// </summary>
|
||||
/// <param name="dispatcher">The shared dispatcher (the same instance the actor routes through).</param>
|
||||
public void SetReady(SiteCommandDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
_dispatcher = dispatcher;
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
/// <summary>Whether the service is accepting commands. Exposed for tests.</summary>
|
||||
internal bool IsReady => _ready;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<LifecycleReply> ExecuteLifecycle(LifecycleRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = SiteCommandDtoMapper.FromLifecycleRequest(request);
|
||||
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
|
||||
return SiteCommandDtoMapper.ToLifecycleReply(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<OpcUaReply> ExecuteOpcUa(OpcUaRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = SiteCommandDtoMapper.FromOpcUaRequest(request);
|
||||
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
|
||||
return SiteCommandDtoMapper.ToOpcUaReply(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<QueryReply> ExecuteQuery(QueryRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = SiteCommandDtoMapper.FromQueryRequest(request);
|
||||
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
|
||||
return SiteCommandDtoMapper.ToQueryReply(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<ParkedReply> ExecuteParked(ParkedRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = SiteCommandDtoMapper.FromParkedRequest(request);
|
||||
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
|
||||
return SiteCommandDtoMapper.ToParkedReply(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<RouteReply> ExecuteRoute(RouteRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = SiteCommandDtoMapper.FromRouteRequest(request);
|
||||
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
|
||||
return SiteCommandDtoMapper.ToRouteReply(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<SiteFailoverAckDto> TriggerFailover(TriggerSiteFailoverDto request, ServerCallContext context)
|
||||
{
|
||||
var dispatcher = EnsureReady();
|
||||
var msg = SiteCommandDtoMapper.FromProto(request);
|
||||
|
||||
// Resolve the standby and build the ack WITHOUT leaving. The real leave is deferred so the
|
||||
// ack is on the wire first (ack-before-Leave).
|
||||
var outcome = dispatcher.PrepareFailover(msg);
|
||||
var dto = SiteCommandDtoMapper.ToProto(outcome.Ack);
|
||||
|
||||
if (outcome.CommitLeave is not null)
|
||||
{
|
||||
_leaveScheduler(outcome.CommitLeave);
|
||||
}
|
||||
|
||||
return Task.FromResult(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a decoded command through the shared dispatcher and returns the reply record.
|
||||
/// Forward → local <c>Ask</c>; fire-and-forget → local <c>Tell</c> + synthetic ack; no target →
|
||||
/// the dispatcher's synthetic "handler not available" reply.
|
||||
/// </summary>
|
||||
private async Task<object> DispatchAsync(object command, ServerCallContext context)
|
||||
{
|
||||
var dispatcher = EnsureReady();
|
||||
var route = dispatcher.ResolveRoute(command);
|
||||
|
||||
switch (route.Disposition)
|
||||
{
|
||||
case SiteCommandDispatcher.RouteDisposition.ImmediateReply:
|
||||
return route.Reply!;
|
||||
|
||||
case SiteCommandDispatcher.RouteDisposition.TellFireAndForget:
|
||||
route.Target!.Tell(command);
|
||||
return route.Reply!;
|
||||
|
||||
default:
|
||||
try
|
||||
{
|
||||
return await route.Target!.Ask<object>(
|
||||
command, AskTimeout(context), context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (RpcException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client cancelled or the deadline elapsed.
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.DeadlineExceeded, "Site did not answer within the deadline."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Local dispatch of {Command} faulted", command.GetType().Name);
|
||||
throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SiteCommandDispatcher EnsureReady()
|
||||
{
|
||||
var dispatcher = _dispatcher;
|
||||
if (!_ready || dispatcher is null)
|
||||
{
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.Unavailable, "Site command plane not ready."));
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
private static TimeSpan AskTimeout(ServerCallContext context)
|
||||
{
|
||||
var deadline = context.Deadline;
|
||||
if (deadline == DateTime.MaxValue)
|
||||
{
|
||||
return DefaultAskTimeout;
|
||||
}
|
||||
|
||||
var remaining = deadline - DateTime.UtcNow;
|
||||
return remaining > TimeSpan.Zero ? remaining : TimeSpan.FromMilliseconds(1);
|
||||
}
|
||||
|
||||
// Default deferred-leave: return control (so the ack serialises) before the graceful Leave
|
||||
// runs. A yield hands the current continuation back before the leave begins; the leave itself
|
||||
// is slow-async (member marked Leaving, CoordinatedShutdown over seconds), so the ack is long
|
||||
// gone by the time Kestrel actually stops.
|
||||
private static void DeferLeaveUntilAfterReply(Action commitLeave)
|
||||
=> _ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Yield();
|
||||
commitLeave();
|
||||
});
|
||||
}
|
||||
@@ -863,7 +863,20 @@ akka {{
|
||||
_nodeOptions.SiteId);
|
||||
}
|
||||
|
||||
// Create SiteCommunicationActor for receiving messages from central
|
||||
// The ONE routing table for central→site commands, shared by the Akka
|
||||
// SiteCommunicationActor (below) and the gRPC SiteCommandGrpcService (SetReady at the end
|
||||
// of this method). Its failover seam resolves (dryRun) or performs the graceful Leave via
|
||||
// the shared ClusterFailoverCoordinator so the central/site paths cannot drift.
|
||||
var siteCommandDispatcher = new SiteCommandDispatcher(
|
||||
_nodeOptions.SiteId!,
|
||||
dmProxy,
|
||||
(role, dryRun) => ZB.MOM.WW.ScadaBridge.Communication.ClusterState.ClusterFailoverCoordinator
|
||||
.FailOverOldest(_actorSystem!, role, dryRun)?.ToString());
|
||||
|
||||
// Create SiteCommunicationActor for receiving messages from central. It routes commands
|
||||
// through the shared dispatcher; RegisterLocalHandler (below) registers the handlers INTO
|
||||
// that dispatcher, so the gRPC command service sees the same registrations. The site→central
|
||||
// transport is selected above (default Akka); both are passed in.
|
||||
var siteCommActor = _actorSystem.ActorOf(
|
||||
Props.Create(() => new SiteCommunicationActor(
|
||||
_nodeOptions.SiteId!,
|
||||
@@ -871,7 +884,8 @@ akka {{
|
||||
dmProxy,
|
||||
activeNodeCheck,
|
||||
null,
|
||||
centralTransport)),
|
||||
centralTransport,
|
||||
siteCommandDispatcher)),
|
||||
"site-communication");
|
||||
|
||||
// Register local handlers with SiteCommunicationActor
|
||||
@@ -1141,5 +1155,12 @@ akka {{
|
||||
grpcServer?.SetOperationTrackingStore(siteTrackingStore);
|
||||
}
|
||||
grpcServer?.SetReady(_actorSystem!);
|
||||
|
||||
// Site command plane: hand the shared dispatcher to the gRPC command service and flip it
|
||||
// ready at the same point as the streaming server — the actor graph exists and the local
|
||||
// handlers have been registered into the dispatcher, so it can route commands now.
|
||||
var commandService = _serviceProvider
|
||||
.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandGrpcService>();
|
||||
commandService?.SetReady(siteCommandDispatcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,18 @@ namespace ZB.MOM.WW.ScadaBridge.Host;
|
||||
public sealed class ControlPlaneAuthInterceptor : Interceptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Service prefixes gated by default. Read from the generated <c>sitestream.proto</c>
|
||||
/// package/service names — <c>package sitestream; service SiteStreamService</c>.
|
||||
/// Later phases append their own services here.
|
||||
/// Service prefixes gated by default. Read from the generated service descriptors —
|
||||
/// <c>package sitestream; service SiteStreamService</c> (real-time data + audit pull) and
|
||||
/// <c>package scadabridge.sitecommand.v1; service SiteCommandService</c> (the T1B command
|
||||
/// plane). Later phases append their own services here rather than adding a second
|
||||
/// interceptor or constructor (see the public constructor's remarks).
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> DefaultGatedPrefixes =
|
||||
new[] { "/sitestream.SiteStreamService/" };
|
||||
new[]
|
||||
{
|
||||
$"/{SiteStreamService.Descriptor.FullName}/",
|
||||
$"/{SiteCommandService.Descriptor.FullName}/",
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<string> _gatedPrefixes;
|
||||
private readonly IOptions<CommunicationOptions> _options;
|
||||
|
||||
@@ -610,6 +610,10 @@ try
|
||||
options.Interceptors.Add<ControlPlaneAuthInterceptor>();
|
||||
});
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
// Site command plane (central→site) — the gRPC peer of SiteStreamGrpcServer. Both share
|
||||
// the h2c listener and the ControlPlaneAuthInterceptor PSK gate; both are readiness-gated
|
||||
// (SetReady, below). Central still dials over ClusterClient until T1B.3.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandGrpcService>();
|
||||
|
||||
// Existing site service registrations (this is also where LocalDb and its
|
||||
// replication engine are registered — see SiteServiceRegistration)
|
||||
@@ -631,6 +635,9 @@ try
|
||||
// Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI
|
||||
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
// Map the site command plane (central→site) alongside it, on the same gated listener.
|
||||
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandGrpcService>();
|
||||
|
||||
// The passive half of LocalDb replication: the peer node dials THIS endpoint.
|
||||
// It shares the Kestrel h2c listener the site gRPC server already uses, so no
|
||||
// listener or port changes are needed. Mapping it is harmless with no peer
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The single routing truth for the 28 migrated central→site commands. Proves each command
|
||||
/// resolves to EXACTLY the target the actor used inline before the extraction — including the
|
||||
/// node-local parked handler (never the singleton proxy) and the local failover path — so the
|
||||
/// Akka actor and the gRPC service can share one table without drift.
|
||||
/// </summary>
|
||||
public class SiteCommandDispatcherTests : TestKit
|
||||
{
|
||||
private const string SiteId = "site1";
|
||||
|
||||
private SiteCommandDispatcher Build(IActorRef dmProxy, Func<string, bool, string?>? failover = null)
|
||||
=> new(SiteId, dmProxy, failover ?? ((_, _) => null));
|
||||
|
||||
// ── The 19 commands that forward to the Deployment Manager singleton proxy ──
|
||||
|
||||
/// <summary>The proxy-routed commands (lifecycle, OPC UA, debug snapshot/subscribe, route).</summary>
|
||||
public static IEnumerable<object[]> ProxyCommandTypes() => new[]
|
||||
{
|
||||
typeof(Commons.Messages.Deployment.RefreshDeploymentCommand),
|
||||
typeof(Commons.Messages.Lifecycle.EnableInstanceCommand),
|
||||
typeof(Commons.Messages.Lifecycle.DisableInstanceCommand),
|
||||
typeof(Commons.Messages.Lifecycle.DeleteInstanceCommand),
|
||||
typeof(Commons.Messages.Deployment.DeploymentStateQueryRequest),
|
||||
typeof(Commons.Messages.Management.BrowseNodeCommand),
|
||||
typeof(Commons.Messages.Management.SearchAddressSpaceCommand),
|
||||
typeof(Commons.Messages.Management.ReadTagValuesCommand),
|
||||
typeof(Commons.Messages.Management.VerifyEndpointCommand),
|
||||
typeof(Commons.Messages.Management.TrustServerCertCommand),
|
||||
typeof(Commons.Messages.Management.ListServerCertsCommand),
|
||||
typeof(Commons.Messages.Management.RemoveServerCertCommand),
|
||||
typeof(Commons.Messages.DataConnection.WriteTagRequest),
|
||||
typeof(Commons.Messages.DebugView.DebugSnapshotRequest),
|
||||
typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest),
|
||||
typeof(Commons.Messages.InboundApi.RouteToCallRequest),
|
||||
typeof(Commons.Messages.InboundApi.RouteToGetAttributesRequest),
|
||||
typeof(Commons.Messages.InboundApi.RouteToSetAttributesRequest),
|
||||
typeof(Commons.Messages.InboundApi.RouteToWaitForAttributeRequest),
|
||||
}.Select(t => new object[] { t });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProxyCommandTypes))]
|
||||
public void ProxyCommands_ForwardToDeploymentManager(Type commandType)
|
||||
{
|
||||
var dm = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
var command = SiteCommandSamples.All[commandType][0];
|
||||
|
||||
var route = dispatcher.ResolveRoute(command);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition);
|
||||
Assert.Same(dm.Ref, route.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsubscribeDebugView_IsFireAndForget_ToDeploymentManager_WithSyntheticAck()
|
||||
{
|
||||
// Fire-and-forget: the Deployment Manager never acks an unsubscribe. The actor Forwards it;
|
||||
// the gRPC transport Tells it and returns this synthetic ack so a unary RPC still answers.
|
||||
var dm = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
var command = SiteCommandSamples.All[typeof(Commons.Messages.DebugView.UnsubscribeDebugViewRequest)][0];
|
||||
|
||||
var route = dispatcher.ResolveRoute(command);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.TellFireAndForget, route.Disposition);
|
||||
Assert.Same(dm.Ref, route.Target);
|
||||
Assert.Same(UnsubscribeDebugViewAck.Instance, route.Reply);
|
||||
}
|
||||
|
||||
// ── Artifact handler (null-guarded) ──
|
||||
|
||||
[Fact]
|
||||
public void DeployArtifacts_WithHandler_ForwardsToArtifactHandler_NotTheProxy()
|
||||
{
|
||||
var dm = CreateTestProbe();
|
||||
var artifact = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
dispatcher.RegisterArtifactHandler(artifact.Ref);
|
||||
|
||||
var route = dispatcher.ResolveRoute(SiteCommandSamples.All[typeof(DeployArtifactsCommand)][0]);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition);
|
||||
Assert.Same(artifact.Ref, route.Target);
|
||||
Assert.NotSame(dm.Ref, route.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeployArtifacts_WithoutHandler_RepliesHandlerNotAvailable()
|
||||
{
|
||||
var dm = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
var command = (DeployArtifactsCommand)SiteCommandSamples.All[typeof(DeployArtifactsCommand)][0];
|
||||
|
||||
var route = dispatcher.ResolveRoute(command);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.ImmediateReply, route.Disposition);
|
||||
var reply = Assert.IsType<ArtifactDeploymentResponse>(route.Reply);
|
||||
Assert.False(reply.Success);
|
||||
Assert.Equal("Artifact handler not available", reply.ErrorMessage);
|
||||
Assert.Equal(command.DeploymentId, reply.DeploymentId);
|
||||
Assert.Equal(SiteId, reply.SiteId);
|
||||
}
|
||||
|
||||
// ── Event-log handler (null-guarded) ──
|
||||
|
||||
[Fact]
|
||||
public void EventLogQuery_WithHandler_ForwardsToEventLogHandler()
|
||||
{
|
||||
var dm = CreateTestProbe();
|
||||
var eventLog = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
dispatcher.RegisterEventLogHandler(eventLog.Ref);
|
||||
|
||||
var route = dispatcher.ResolveRoute(SiteCommandSamples.All[typeof(EventLogQueryRequest)][0]);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition);
|
||||
Assert.Same(eventLog.Ref, route.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventLogQuery_WithoutHandler_RepliesHandlerNotAvailable()
|
||||
{
|
||||
var dm = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
var command = (EventLogQueryRequest)SiteCommandSamples.All[typeof(EventLogQueryRequest)][0];
|
||||
|
||||
var route = dispatcher.ResolveRoute(command);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.ImmediateReply, route.Disposition);
|
||||
var reply = Assert.IsType<EventLogQueryResponse>(route.Reply);
|
||||
Assert.False(reply.Success);
|
||||
Assert.Equal(command.CorrelationId, reply.CorrelationId);
|
||||
}
|
||||
|
||||
// ── Parked handler (null-guarded) — stays NODE-LOCAL on purpose (replicated store) ──
|
||||
|
||||
/// <summary>The five parked commands, each of which routes to the per-node parked handler.</summary>
|
||||
public static IEnumerable<object[]> ParkedCommandTypes() => new[]
|
||||
{
|
||||
typeof(ParkedMessageQueryRequest),
|
||||
typeof(ParkedMessageRetryRequest),
|
||||
typeof(ParkedMessageDiscardRequest),
|
||||
typeof(RetryParkedOperation),
|
||||
typeof(DiscardParkedOperation),
|
||||
}.Select(t => new object[] { t });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParkedCommandTypes))]
|
||||
public void ParkedCommands_WithHandler_RouteToNodeLocalParkedHandler_NeverTheProxy(Type commandType)
|
||||
{
|
||||
// Node-locality proof: a parked retry/discard must run on the node holding the replicated
|
||||
// store row, so it goes to the per-node parked handler — NEVER the singleton proxy. This is
|
||||
// the constraint the extraction must not "fix".
|
||||
var dm = CreateTestProbe();
|
||||
var parked = CreateTestProbe();
|
||||
var dispatcher = Build(dm.Ref);
|
||||
dispatcher.RegisterParkedMessageHandler(parked.Ref);
|
||||
|
||||
var route = dispatcher.ResolveRoute(SiteCommandSamples.All[commandType][0]);
|
||||
|
||||
Assert.Equal(SiteCommandDispatcher.RouteDisposition.Forward, route.Disposition);
|
||||
Assert.Same(parked.Ref, route.Target);
|
||||
Assert.NotSame(dm.Ref, route.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParkedMessageQuery_WithoutHandler_RepliesHandlerNotAvailable()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
var command = (ParkedMessageQueryRequest)SiteCommandSamples.All[typeof(ParkedMessageQueryRequest)][0];
|
||||
|
||||
var route = dispatcher.ResolveRoute(command);
|
||||
|
||||
var reply = Assert.IsType<ParkedMessageQueryResponse>(route.Reply);
|
||||
Assert.False(reply.Success);
|
||||
Assert.Equal(command.CorrelationId, reply.CorrelationId);
|
||||
Assert.Equal(command.PageNumber, reply.PageNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParkedMessageRetry_WithoutHandler_RepliesHandlerNotAvailable()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
var command = (ParkedMessageRetryRequest)SiteCommandSamples.All[typeof(ParkedMessageRetryRequest)][0];
|
||||
|
||||
var reply = Assert.IsType<ParkedMessageRetryResponse>(dispatcher.ResolveRoute(command).Reply);
|
||||
Assert.False(reply.Success);
|
||||
Assert.Equal(command.CorrelationId, reply.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParkedMessageDiscard_WithoutHandler_RepliesHandlerNotAvailable()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
var command = (ParkedMessageDiscardRequest)SiteCommandSamples.All[typeof(ParkedMessageDiscardRequest)][0];
|
||||
|
||||
var reply = Assert.IsType<ParkedMessageDiscardResponse>(dispatcher.ResolveRoute(command).Reply);
|
||||
Assert.False(reply.Success);
|
||||
Assert.Equal(command.CorrelationId, reply.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
var command = new RetryParkedOperation("corr-x", TrackedOperationId.New());
|
||||
|
||||
var reply = Assert.IsType<ParkedOperationActionAck>(dispatcher.ResolveRoute(command).Reply);
|
||||
Assert.False(reply.Applied);
|
||||
Assert.Equal("corr-x", reply.CorrelationId);
|
||||
Assert.NotNull(reply.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
var command = new DiscardParkedOperation("corr-y", TrackedOperationId.New());
|
||||
|
||||
var reply = Assert.IsType<ParkedOperationActionAck>(dispatcher.ResolveRoute(command).Reply);
|
||||
Assert.False(reply.Applied);
|
||||
Assert.Equal("corr-y", reply.CorrelationId);
|
||||
}
|
||||
|
||||
// ── Commands that must NOT enter ResolveRoute ──
|
||||
|
||||
[Fact]
|
||||
public void ResolveRoute_RejectsFailover_ItGoesThroughPrepareFailover()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
dispatcher.ResolveRoute(new TriggerSiteFailover("c", SiteId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRoute_RejectsTheExcludedIntegrationCommand()
|
||||
{
|
||||
// IntegrationCallRequest is the 29th command, dead at both ends and deliberately excluded
|
||||
// (28 of 29 migrate). It never enters the dispatcher.
|
||||
var dispatcher = Build(CreateTestProbe().Ref);
|
||||
var command = new IntegrationCallRequest(
|
||||
"c", SiteId, "inst", "es", "m", new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => dispatcher.ResolveRoute(command));
|
||||
}
|
||||
|
||||
// ── Failover (local path) ──
|
||||
|
||||
[Fact]
|
||||
public void HandleFailover_ResolvesAndLeaves_ThenAcksWithTheTarget()
|
||||
{
|
||||
string? roleAsked = null;
|
||||
var leaveIssued = false;
|
||||
Func<string, bool, string?> resolve = (role, dryRun) =>
|
||||
{
|
||||
roleAsked = role;
|
||||
leaveIssued = !dryRun;
|
||||
return "akka.tcp://scadabridge@site1-a:8082";
|
||||
};
|
||||
var dispatcher = Build(CreateTestProbe().Ref, resolve);
|
||||
|
||||
var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-1", SiteId));
|
||||
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.Equal("corr-1", ack.CorrelationId);
|
||||
Assert.Equal("akka.tcp://scadabridge@site1-a:8082", ack.TargetAddress);
|
||||
Assert.Null(ack.ErrorMessage);
|
||||
// Site singletons are scoped to the site-specific role.
|
||||
Assert.Equal("site-site1", roleAsked);
|
||||
// The actor path leaves in one step (dryRun:false).
|
||||
Assert.True(leaveIssued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleFailover_RefusesWhenThereIsNoPeer()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref, (_, _) => null);
|
||||
|
||||
var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-2", SiteId));
|
||||
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.Null(ack.TargetAddress);
|
||||
Assert.NotNull(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleFailover_RefusesACommandAddressedToAnotherSite_WithoutTouchingTheResolver()
|
||||
{
|
||||
var invoked = false;
|
||||
var dispatcher = Build(CreateTestProbe().Ref, (_, _) => { invoked = true; return "addr"; });
|
||||
|
||||
var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-3", "site2"));
|
||||
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.Contains("site2", ack.ErrorMessage);
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleFailover_FaultInTheLeave_IsReportedNotThrown()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref,
|
||||
(_, _) => throw new InvalidOperationException("cluster unavailable"));
|
||||
|
||||
var ack = dispatcher.HandleFailover(new TriggerSiteFailover("corr-4", SiteId));
|
||||
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.Contains("cluster unavailable", ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareFailover_BuildsTheAckFromADryRun_WithoutLeaving_UntilCommitLeaveRuns()
|
||||
{
|
||||
// The gRPC ack-before-Leave proof at the routing-truth level: PrepareFailover resolves the
|
||||
// standby with a DRY-RUN only (so the ack is built without leaving), and hands back a
|
||||
// deferred CommitLeave. Only invoking CommitLeave — what the gRPC service does AFTER the
|
||||
// ack is on the wire — performs the real leave.
|
||||
var events = new List<string>();
|
||||
Func<string, bool, string?> resolve = (_, dryRun) =>
|
||||
{
|
||||
events.Add(dryRun ? "resolve" : "leave");
|
||||
return "akka.tcp://scadabridge@site1-a:8082";
|
||||
};
|
||||
var dispatcher = Build(CreateTestProbe().Ref, resolve);
|
||||
|
||||
var outcome = dispatcher.PrepareFailover(new TriggerSiteFailover("corr-5", SiteId));
|
||||
|
||||
Assert.True(outcome.Ack.Accepted);
|
||||
Assert.Equal("akka.tcp://scadabridge@site1-a:8082", outcome.Ack.TargetAddress);
|
||||
Assert.NotNull(outcome.CommitLeave);
|
||||
// Building the ack did NOT leave.
|
||||
Assert.Equal(new[] { "resolve" }, events);
|
||||
|
||||
outcome.CommitLeave!();
|
||||
// The leave runs strictly after — the caller sends the ack first.
|
||||
Assert.Equal(new[] { "resolve", "leave" }, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareFailover_WhenRefused_HasNoDeferredLeave()
|
||||
{
|
||||
var dispatcher = Build(CreateTestProbe().Ref, (_, _) => null);
|
||||
|
||||
var outcome = dispatcher.PrepareFailover(new TriggerSiteFailover("corr-6", SiteId));
|
||||
|
||||
Assert.False(outcome.Ack.Accepted);
|
||||
Assert.Null(outcome.CommitLeave);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using Akka.Actor;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
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.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The site command plane's gRPC front door (T1B.2): the same PSK gate and readiness convention
|
||||
/// as <c>SiteStreamGrpcServer</c>, and a decode→dispatch→encode round trip through the ONE
|
||||
/// <see cref="SiteCommandDispatcher"/> for one command in each of the six oneof groups. Runs
|
||||
/// in-process over <see cref="TestServer"/> — no ports, no containers — with the interceptor
|
||||
/// registered BY TYPE on <c>AddGrpc</c> (never pre-registered in DI), the shape that keeps
|
||||
/// <c>Grpc.AspNetCore</c>'s own activation in the picture. See
|
||||
/// <see cref="ControlPlaneAuthEndToEndTests"/> for why that matters.
|
||||
/// </summary>
|
||||
public sealed class SiteCommandGrpcServiceTests : IDisposable
|
||||
{
|
||||
private const string SiteKey = "the-site-a-command-key";
|
||||
private const string SiteId = "site-a";
|
||||
|
||||
private readonly ActorSystem _system = ActorSystem.Create("sitecmd-tests");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _system.Dispose();
|
||||
|
||||
/// <summary>A dispatcher whose Deployment Manager proxy is a canned-reply responder.</summary>
|
||||
private SiteCommandDispatcher DispatcherWithResponder(out IActorRef responder)
|
||||
{
|
||||
responder = _system.ActorOf(Props.Create(() => new Responder()));
|
||||
var dispatcher = new SiteCommandDispatcher(SiteId, responder, (_, _) => null);
|
||||
// The parked handler is node-local; point it at the responder too so the Parked group can
|
||||
// be exercised end-to-end (the null-guard path is covered by the dispatcher unit tests).
|
||||
dispatcher.RegisterParkedMessageHandler(responder);
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
private SiteCommandGrpcService ReadyService()
|
||||
{
|
||||
var service = new SiteCommandGrpcService(NullLogger<SiteCommandGrpcService>.Instance);
|
||||
service.SetReady(DispatcherWithResponder(out _));
|
||||
return service;
|
||||
}
|
||||
|
||||
private static async Task<IHost> StartHost(SiteCommandGrpcService service)
|
||||
=> await new HostBuilder()
|
||||
.ConfigureWebHost(web => web
|
||||
.UseTestServer()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// BY TYPE on AddGrpc — never AddSingleton the interceptor (see the sibling test).
|
||||
services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>());
|
||||
services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey }));
|
||||
services.AddSingleton(service);
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(e => e.MapGrpcService<SiteCommandGrpcService>());
|
||||
}))
|
||||
.StartAsync();
|
||||
|
||||
private static SiteCommandService.SiteCommandServiceClient Client(IHost host, string? key)
|
||||
{
|
||||
var server = host.GetTestServer();
|
||||
var options = new GrpcChannelOptions { HttpHandler = server.CreateHandler() };
|
||||
if (key is not null)
|
||||
{
|
||||
options.WithSiteCredentials(new FixedPskProvider(key), SiteId);
|
||||
}
|
||||
var channel = GrpcChannel.ForAddress(server.BaseAddress, options);
|
||||
return new SiteCommandService.SiteCommandServiceClient(channel);
|
||||
}
|
||||
|
||||
private sealed class FixedPskProvider(string key) : ISitePskProvider
|
||||
{
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
|
||||
// ── Auth gating (delegated to ControlPlaneAuthInterceptor, proven wired here) ──
|
||||
|
||||
[Fact]
|
||||
public async Task NoCredentials_IsRejected_WithPermissionDenied()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, key: null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => client.ExecuteQueryAsync(
|
||||
new QueryRequest { UnsubscribeDebugView = new UnsubscribeDebugViewRequestDto() }).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongKey_IsRejected_WithPermissionDenied()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, "some-other-key");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => client.ExecuteQueryAsync(
|
||||
new QueryRequest { UnsubscribeDebugView = new UnsubscribeDebugViewRequestDto() }).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
// ── Readiness ──
|
||||
|
||||
[Fact]
|
||||
public async Task BeforeSetReady_IsRejected_WithUnavailable_EvenWithACorrectKey()
|
||||
{
|
||||
// Auth passes (correct key); the readiness gate then rejects until the site actor graph is up.
|
||||
var unready = new SiteCommandGrpcService(NullLogger<SiteCommandGrpcService>.Instance);
|
||||
using var host = await StartHost(unready);
|
||||
var client = Client(host, SiteKey);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => client.ExecuteLifecycleAsync(
|
||||
new LifecycleRequest { EnableInstance = new EnableInstanceCommandDto { CommandId = "c" } }).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
||||
}
|
||||
|
||||
// ── decode → dispatch → encode, one command per oneof group ──
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteLifecycle_RoundTripsThroughTheDispatcher()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, SiteKey);
|
||||
var command = new EnableInstanceCommand("cmd-1", "Site1.Pump1", DateTimeOffset.UtcNow);
|
||||
|
||||
var reply = await client.ExecuteLifecycleAsync(
|
||||
new LifecycleRequest { EnableInstance = SiteCommandDtoMapper.ToProto(command) });
|
||||
|
||||
var decoded = Assert.IsType<InstanceLifecycleResponse>(SiteCommandDtoMapper.FromLifecycleReply(reply));
|
||||
Assert.True(decoded.Success);
|
||||
Assert.Equal("cmd-1", decoded.CommandId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteOpcUa_RoundTripsThroughTheDispatcher()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, SiteKey);
|
||||
var command = new BrowseNodeCommand("conn", null, null, null);
|
||||
|
||||
var reply = await client.ExecuteOpcUaAsync(
|
||||
new OpcUaRequest { BrowseNode = SiteCommandDtoMapper.ToProto(command) });
|
||||
|
||||
Assert.IsType<BrowseNodeResult>(SiteCommandDtoMapper.FromOpcUaReply(reply));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteQuery_DebugSnapshot_RoundTripsThroughTheDispatcher()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, SiteKey);
|
||||
var command = new DebugSnapshotRequest("Site1.Pump1", "corr-4");
|
||||
|
||||
var reply = await client.ExecuteQueryAsync(
|
||||
new QueryRequest { DebugSnapshot = SiteCommandDtoMapper.ToProto(command) });
|
||||
|
||||
var decoded = Assert.IsType<DebugViewSnapshot>(SiteCommandDtoMapper.FromQueryReply(reply));
|
||||
Assert.Equal("Site1.Pump1", decoded.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteQuery_UnsubscribeDebugView_ReturnsTheFireAndForgetAck()
|
||||
{
|
||||
// Fire-and-forget: the service Tells the target and returns the synthetic ack so a unary
|
||||
// RPC still answers, keeping the caller-visible fire-and-forget semantics.
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, SiteKey);
|
||||
|
||||
var reply = await client.ExecuteQueryAsync(new QueryRequest
|
||||
{
|
||||
UnsubscribeDebugView = SiteCommandDtoMapper.ToProto(
|
||||
new UnsubscribeDebugViewRequest("Site1.Pump1", "corr-6")),
|
||||
});
|
||||
|
||||
Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, reply.ReplyCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteParked_RoundTripsThroughTheNodeLocalHandler()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, SiteKey);
|
||||
var command = new ParkedMessageQueryRequest("corr-7", SiteId, 2, 25, DateTimeOffset.UtcNow);
|
||||
|
||||
var reply = await client.ExecuteParkedAsync(
|
||||
new ParkedRequest { ParkedMessageQuery = SiteCommandDtoMapper.ToProto(command) });
|
||||
|
||||
var decoded = Assert.IsType<ParkedMessageQueryResponse>(SiteCommandDtoMapper.FromParkedReply(reply));
|
||||
Assert.True(decoded.Success);
|
||||
Assert.Equal("corr-7", decoded.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRoute_RoundTripsThroughTheDispatcher()
|
||||
{
|
||||
using var host = await StartHost(ReadyService());
|
||||
var client = Client(host, SiteKey);
|
||||
var command = new RouteToCallRequest(
|
||||
"corr-12", "Site1.Pump1", "Start", new Dictionary<string, object?>(), DateTimeOffset.UtcNow, null);
|
||||
|
||||
var reply = await client.ExecuteRouteAsync(
|
||||
new RouteRequest { RouteToCall = SiteCommandDtoMapper.ToProto(command) });
|
||||
|
||||
var decoded = Assert.IsType<RouteToCallResponse>(SiteCommandDtoMapper.FromRouteReply(reply));
|
||||
Assert.True(decoded.Success);
|
||||
Assert.Equal("corr-12", decoded.CorrelationId);
|
||||
}
|
||||
|
||||
// ── Failover: the reply completes before the leave is initiated ──
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerFailover_ReturnsTheAck_BeforeTheLeaveIsInitiated()
|
||||
{
|
||||
// The resolver records the order of its calls: PrepareFailover does a DRY-RUN resolve to
|
||||
// build the ack (recorded synchronously, before the RPC returns), and the real leave runs
|
||||
// only on the deferred CommitLeave — so the recorded order is always resolve-then-leave,
|
||||
// i.e. the ack is on the wire before the node begins leaving.
|
||||
var events = new List<string>();
|
||||
var leaveHappened = new ManualResetEventSlim(false);
|
||||
Func<string, bool, string?> resolve = (_, dryRun) =>
|
||||
{
|
||||
lock (events) { events.Add(dryRun ? "resolve" : "leave"); }
|
||||
if (!dryRun) leaveHappened.Set();
|
||||
return "akka.tcp://scadabridge@site-a-node-a:8082";
|
||||
};
|
||||
|
||||
var service = new SiteCommandGrpcService(NullLogger<SiteCommandGrpcService>.Instance);
|
||||
var dispatcher = new SiteCommandDispatcher(SiteId, _system.ActorOf(Props.Create(() => new Responder())), resolve);
|
||||
service.SetReady(dispatcher);
|
||||
|
||||
using var host = await StartHost(service);
|
||||
var client = Client(host, SiteKey);
|
||||
|
||||
var reply = await client.TriggerFailoverAsync(
|
||||
new TriggerSiteFailoverDto { CorrelationId = "corr-16", SiteId = SiteId });
|
||||
|
||||
Assert.True(reply.Accepted);
|
||||
Assert.Equal("akka.tcp://scadabridge@site-a-node-a:8082", reply.TargetAddress);
|
||||
|
||||
Assert.True(leaveHappened.Wait(TimeSpan.FromSeconds(5)), "the deferred leave never ran");
|
||||
lock (events)
|
||||
{
|
||||
Assert.Equal(new[] { "resolve", "leave" }, events);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deployment Manager stand-in: replies each command with a canned reply of the right group.</summary>
|
||||
private sealed class Responder : ReceiveActor
|
||||
{
|
||||
public Responder() => ReceiveAny(msg => Sender.Tell(ReplyFor(msg)));
|
||||
|
||||
private static object ReplyFor(object m) => m switch
|
||||
{
|
||||
EnableInstanceCommand e =>
|
||||
new InstanceLifecycleResponse(e.CommandId, e.InstanceUniqueName, true, null, DateTimeOffset.UtcNow),
|
||||
BrowseNodeCommand => new BrowseNodeResult([], false, null, null),
|
||||
DebugSnapshotRequest d => new DebugViewSnapshot(d.InstanceUniqueName, [], [], DateTimeOffset.UtcNow),
|
||||
RouteToCallRequest r =>
|
||||
new RouteToCallResponse(r.CorrelationId, true, null, null, DateTimeOffset.UtcNow),
|
||||
ParkedMessageQueryRequest p => new ParkedMessageQueryResponse(
|
||||
p.CorrelationId, p.SiteId, [], 0, p.PageNumber, p.PageSize, true, null, DateTimeOffset.UtcNow),
|
||||
_ => new Akka.Actor.Status.Failure(new InvalidOperationException($"no canned reply for {m.GetType().Name}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user