using Akka.Actor; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Messages.Integration; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Messages.RemoteQuery; using ScadaLink.Communication.Actors; namespace ScadaLink.Communication; /// /// Central-side service that wraps the Akka Ask pattern with per-pattern timeouts. /// Provides a typed API for sending messages to sites and awaiting responses. /// On connection drop, the ask times out (no central buffering per design). /// public class CommunicationService { private readonly CommunicationOptions _options; private readonly ILogger _logger; private IActorRef? _centralCommunicationActor; private IActorRef? _notificationOutboxProxy; private IActorRef? _siteCallAuditProxy; public CommunicationService( IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } /// /// Sets the central communication actor reference. Called during actor system startup. /// public void SetCommunicationActor(IActorRef centralCommunicationActor) { _centralCommunicationActor = centralCommunicationActor; } /// /// Sets the notification-outbox singleton proxy reference. Called during actor /// system startup. The outbox actor is central-local, so outbox calls Ask this /// proxy directly (no SiteEnvelope routing). /// public void SetNotificationOutbox(IActorRef notificationOutboxProxy) { _notificationOutboxProxy = notificationOutboxProxy; } /// /// Sets the Site Call Audit (#22) singleton proxy reference. Called during /// actor system startup. The Site Call Audit actor is central-local, so Site /// Calls read calls Ask this proxy directly (no SiteEnvelope routing), the /// same pattern as . /// public void SetSiteCallAudit(IActorRef siteCallAuditProxy) { _siteCallAuditProxy = siteCallAuditProxy; } /// /// Triggers an immediate refresh of the site address cache from the database. /// public void RefreshSiteAddresses() { GetActor().Tell(new RefreshSiteAddresses()); } /// /// Gets the central communication actor reference. Throws if not yet initialized. /// public IActorRef GetCommunicationActor() { return _centralCommunicationActor ?? throw new InvalidOperationException("CommunicationService not initialized. CentralCommunicationActor not set."); } private IActorRef GetActor() => GetCommunicationActor(); /// /// Gets the notification-outbox proxy reference. Throws if not yet initialized. /// private IActorRef GetNotificationOutbox() { return _notificationOutboxProxy ?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set."); } /// /// Gets the Site Call Audit proxy reference. Throws if not yet initialized. /// private IActorRef GetSiteCallAudit() { return _siteCallAuditProxy ?? throw new InvalidOperationException("CommunicationService not initialized. SiteCallAudit proxy not set."); } // ── Pattern 1: Instance Deployment ── public async Task DeployInstanceAsync( string siteId, DeployInstanceCommand command, CancellationToken cancellationToken = default) { _logger.LogDebug( "Sending DeployInstanceCommand to site {SiteId}, instance={Instance}, correlationId={DeploymentId}", siteId, command.InstanceUniqueName, command.DeploymentId); var envelope = new SiteEnvelope(siteId, command); return await GetActor().Ask( envelope, _options.DeploymentTimeout, cancellationToken); } /// /// DeploymentManager-006: queries a site for the currently-applied deployment /// identity of a single instance. Used by the Deployment Manager before a /// re-deploy to reconcile against the site's actual state. Sent over the /// existing ClusterClient command/control transport; the Ask times out (no /// central buffering) if the site is unreachable, and the caller falls /// through to a normal deploy. /// public async Task QueryDeploymentStateAsync( string siteId, DeploymentStateQueryRequest request, CancellationToken cancellationToken = default) { _logger.LogDebug( "Sending DeploymentStateQueryRequest to site {SiteId}, instance={Instance}, correlationId={CorrelationId}", siteId, request.InstanceUniqueName, request.CorrelationId); var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } // ── Pattern 2: Lifecycle ── public async Task DisableInstanceAsync( string siteId, DisableInstanceCommand command, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, command); return await GetActor().Ask( envelope, _options.LifecycleTimeout, cancellationToken); } public async Task EnableInstanceAsync( string siteId, EnableInstanceCommand command, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, command); return await GetActor().Ask( envelope, _options.LifecycleTimeout, cancellationToken); } public async Task DeleteInstanceAsync( string siteId, DeleteInstanceCommand command, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, command); return await GetActor().Ask( envelope, _options.LifecycleTimeout, cancellationToken); } // ── Pattern 3: Artifact Deployment ── public async Task DeployArtifactsAsync( string siteId, DeployArtifactsCommand command, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, command); return await GetActor().Ask( envelope, _options.ArtifactDeploymentTimeout, cancellationToken); } // ── Pattern 4: Integration Routing ── public async Task RouteIntegrationCallAsync( string siteId, IntegrationCallRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.IntegrationTimeout, cancellationToken); } // ── Pattern 5: Debug View ── public async Task SubscribeDebugViewAsync( string siteId, SubscribeDebugViewRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.DebugViewTimeout, cancellationToken); } public void UnsubscribeDebugView(string siteId, UnsubscribeDebugViewRequest request) { // Tell (fire-and-forget) — no response expected GetActor().Tell(new SiteEnvelope(siteId, request)); } // ── Pattern 6a: Debug Snapshot (one-shot, request/response) ── public async Task RequestDebugSnapshotAsync( string siteId, DebugSnapshotRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } // ── Pattern 6b: Health Reporting (site→central, Tell) ── // Health reports are received by central, not sent. No method needed here. // ── Pattern 7: Remote Queries ── public async Task QueryEventLogsAsync( string siteId, EventLogQueryRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } public async Task QueryParkedMessagesAsync( string siteId, ParkedMessageQueryRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } public async Task RetryParkedMessageAsync( string siteId, ParkedMessageRetryRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } public async Task DiscardParkedMessageAsync( string siteId, ParkedMessageDiscardRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } // ── Pattern 8: Heartbeat (site→central, Tell) ── // Heartbeats are received by central, not sent. No method needed here. // ── Inbound API Cross-Site Routing (WP-4) ── public async Task RouteToCallAsync( string siteId, RouteToCallRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.IntegrationTimeout, cancellationToken); } public async Task RouteToGetAttributesAsync( string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.IntegrationTimeout, cancellationToken); } public async Task RouteToSetAttributesAsync( string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, request); return await GetActor().Ask( envelope, _options.IntegrationTimeout, cancellationToken); } // ── Notification Outbox (central-local actor — Asked directly, no SiteEnvelope) ── public async Task QueryNotificationOutboxAsync( NotificationOutboxQueryRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task RetryNotificationAsync( RetryNotificationRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task DiscardNotificationAsync( DiscardNotificationRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task GetNotificationDetailAsync( NotificationDetailRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task GetNotificationKpisAsync( NotificationKpiRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task GetPerSiteNotificationKpisAsync( PerSiteNotificationKpiRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } // ── Site Call Audit (central-local actor — Asked directly, no SiteEnvelope) ── public async Task QuerySiteCallsAsync( SiteCallQueryRequest request, CancellationToken cancellationToken = default) { return await GetSiteCallAudit().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task GetSiteCallDetailAsync( SiteCallDetailRequest request, CancellationToken cancellationToken = default) { return await GetSiteCallAudit().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task GetSiteCallKpisAsync( SiteCallKpiRequest request, CancellationToken cancellationToken = default) { return await GetSiteCallAudit().Ask( request, _options.QueryTimeout, cancellationToken); } public async Task GetPerSiteSiteCallKpisAsync( PerSiteSiteCallKpiRequest request, CancellationToken cancellationToken = default) { return await GetSiteCallAudit().Ask( request, _options.QueryTimeout, cancellationToken); } /// /// Task 5 (#22): relays an operator Retry of a parked cached call to its /// owning site. The SiteCallAuditActor is Asked directly (it is /// central-local); it in turn relays a RetryParkedOperation to the /// owning site and replies a carrying a /// distinct site-unreachable outcome. Central never mutates the central /// SiteCalls mirror row. /// public async Task RetrySiteCallAsync( RetrySiteCallRequest request, CancellationToken cancellationToken = default) { return await GetSiteCallAudit().Ask( request, _options.QueryTimeout, cancellationToken); } /// /// Task 5 (#22): relays an operator Discard of a parked cached call to its /// owning site. See for the routing and /// source-of-truth rationale. /// public async Task DiscardSiteCallAsync( DiscardSiteCallRequest request, CancellationToken cancellationToken = default) { return await GetSiteCallAudit().Ask( request, _options.QueryTimeout, cancellationToken); } } /// /// Envelope that wraps any message with a target site ID for routing. /// Used by CentralCommunicationActor to resolve the site actor path. /// public record SiteEnvelope(string SiteId, object Message);