diff --git a/src/ScadaLink.Commons/Messages/DataConnection/DataConnectionHealthReport.cs b/src/ScadaLink.Commons/Messages/DataConnection/DataConnectionHealthReport.cs new file mode 100644 index 0000000..4c4fc7d --- /dev/null +++ b/src/ScadaLink.Commons/Messages/DataConnection/DataConnectionHealthReport.cs @@ -0,0 +1,13 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Messages.DataConnection; + +/// +/// Health metrics for a single data connection, contributed to the site health report. +/// +public record DataConnectionHealthReport( + string ConnectionName, + ConnectionHealth Status, + int TotalSubscribedTags, + int ResolvedTags, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/DataConnection/SubscribeTagsRequest.cs b/src/ScadaLink.Commons/Messages/DataConnection/SubscribeTagsRequest.cs new file mode 100644 index 0000000..2288b01 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/DataConnection/SubscribeTagsRequest.cs @@ -0,0 +1,11 @@ +namespace ScadaLink.Commons.Messages.DataConnection; + +/// +/// Request from an Instance Actor to subscribe to tag values through the DCL. +/// +public record SubscribeTagsRequest( + string CorrelationId, + string InstanceUniqueName, + string ConnectionName, + IReadOnlyList TagPaths, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/DataConnection/SubscribeTagsResponse.cs b/src/ScadaLink.Commons/Messages/DataConnection/SubscribeTagsResponse.cs new file mode 100644 index 0000000..246f4c4 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/DataConnection/SubscribeTagsResponse.cs @@ -0,0 +1,11 @@ +namespace ScadaLink.Commons.Messages.DataConnection; + +/// +/// Response confirming tag subscription registration. +/// +public record SubscribeTagsResponse( + string CorrelationId, + string InstanceUniqueName, + bool Success, + string? ErrorMessage, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/DataConnection/TagValueUpdate.cs b/src/ScadaLink.Commons/Messages/DataConnection/TagValueUpdate.cs new file mode 100644 index 0000000..7619a03 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/DataConnection/TagValueUpdate.cs @@ -0,0 +1,21 @@ +using ScadaLink.Commons.Interfaces.Protocol; + +namespace ScadaLink.Commons.Messages.DataConnection; + +/// +/// Published by DCL to an Instance Actor when a subscribed tag value changes. +/// +public record TagValueUpdate( + string ConnectionName, + string TagPath, + object? Value, + QualityCode Quality, + DateTimeOffset Timestamp); + +/// +/// Published by DCL when connection state changes, causing bulk quality updates. +/// +public record ConnectionQualityChanged( + string ConnectionName, + QualityCode Quality, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/DataConnection/UnsubscribeTagsRequest.cs b/src/ScadaLink.Commons/Messages/DataConnection/UnsubscribeTagsRequest.cs new file mode 100644 index 0000000..f9b1d79 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/DataConnection/UnsubscribeTagsRequest.cs @@ -0,0 +1,10 @@ +namespace ScadaLink.Commons.Messages.DataConnection; + +/// +/// Request from an Instance Actor to unsubscribe from all its tags when stopping. +/// +public record UnsubscribeTagsRequest( + string CorrelationId, + string InstanceUniqueName, + string ConnectionName, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/DataConnection/WriteTagRequest.cs b/src/ScadaLink.Commons/Messages/DataConnection/WriteTagRequest.cs new file mode 100644 index 0000000..b00e19e --- /dev/null +++ b/src/ScadaLink.Commons/Messages/DataConnection/WriteTagRequest.cs @@ -0,0 +1,21 @@ +namespace ScadaLink.Commons.Messages.DataConnection; + +/// +/// Request to write a value to a device tag through the DCL. +/// Write failures are returned synchronously to the calling script. +/// +public record WriteTagRequest( + string CorrelationId, + string ConnectionName, + string TagPath, + object? Value, + DateTimeOffset Timestamp); + +/// +/// Response for a device tag write operation. +/// +public record WriteTagResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/Integration/IntegrationCallRequest.cs b/src/ScadaLink.Commons/Messages/Integration/IntegrationCallRequest.cs new file mode 100644 index 0000000..5cd9f1b --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/IntegrationCallRequest.cs @@ -0,0 +1,14 @@ +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Request routed from central to site to invoke an integration method +/// (external system call or notification) on behalf of the central UI or API. +/// +public record IntegrationCallRequest( + string CorrelationId, + string SiteId, + string InstanceUniqueName, + string TargetSystemName, + string MethodName, + IReadOnlyDictionary Parameters, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/Integration/IntegrationCallResponse.cs b/src/ScadaLink.Commons/Messages/Integration/IntegrationCallResponse.cs new file mode 100644 index 0000000..5969f75 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/IntegrationCallResponse.cs @@ -0,0 +1,12 @@ +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Response for an integration call routed through central-site communication. +/// +public record IntegrationCallResponse( + string CorrelationId, + string SiteId, + bool Success, + string? ResultJson, + string? ErrorMessage, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs new file mode 100644 index 0000000..0808fa6 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs @@ -0,0 +1,19 @@ +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// Request to query site event logs from central. +/// Supports filtering by event type, severity, instance, time range, and keyword search. +/// Uses keyset pagination via continuation token (last event ID). +/// +public record EventLogQueryRequest( + string CorrelationId, + string SiteId, + DateTimeOffset? From, + DateTimeOffset? To, + string? EventType, + string? Severity, + string? InstanceId, + string? KeywordFilter, + long? ContinuationToken, + int PageSize, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryResponse.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryResponse.cs new file mode 100644 index 0000000..8bae177 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryResponse.cs @@ -0,0 +1,28 @@ +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// A single event log entry returned from a site query. +/// +public record EventLogEntry( + long Id, + DateTimeOffset Timestamp, + string EventType, + string Severity, + string? InstanceId, + string Source, + string Message, + string? Details); + +/// +/// Response containing paginated event log entries from a site. +/// Uses keyset pagination: ContinuationToken is the last event ID in the result set. +/// +public record EventLogQueryResponse( + string CorrelationId, + string SiteId, + IReadOnlyList Entries, + long? ContinuationToken, + bool HasMore, + bool Success, + string? ErrorMessage, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryRequest.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryRequest.cs new file mode 100644 index 0000000..2f26f71 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryRequest.cs @@ -0,0 +1,11 @@ +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// Request to query parked (permanently failed) store-and-forward messages at a site. +/// +public record ParkedMessageQueryRequest( + string CorrelationId, + string SiteId, + int PageNumber, + int PageSize, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs new file mode 100644 index 0000000..944b444 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs @@ -0,0 +1,24 @@ +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// Response containing parked store-and-forward messages from a site. +/// +public record ParkedMessageEntry( + string MessageId, + string TargetSystem, + string MethodName, + string ErrorMessage, + int AttemptCount, + DateTimeOffset OriginalTimestamp, + DateTimeOffset LastAttemptTimestamp); + +public record ParkedMessageQueryResponse( + string CorrelationId, + string SiteId, + IReadOnlyList Messages, + int TotalCount, + int PageNumber, + int PageSize, + bool Success, + string? ErrorMessage, + DateTimeOffset Timestamp); diff --git a/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs b/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs new file mode 100644 index 0000000..8b50550 --- /dev/null +++ b/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs @@ -0,0 +1,172 @@ +using Akka.Actor; +using Akka.Event; +using ScadaLink.Commons.Messages.Communication; +using ScadaLink.Commons.Messages.Health; + +namespace ScadaLink.Communication.Actors; + +/// +/// Central-side actor that routes messages from central to site clusters via Akka remoting. +/// Maintains a registry of known site actor paths (learned from heartbeats/connection events). +/// +/// WP-4: All 8 message patterns routed through this actor. +/// WP-5: Ask timeout on connection drop (no central buffering). Debug streams killed on interruption. +/// +public class CentralCommunicationActor : ReceiveActor +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + + /// + /// Maps SiteId → remote SiteCommunicationActor selection. + /// Updated when heartbeats arrive or connection state changes. + /// + private readonly Dictionary _siteSelections = new(); + + /// + /// Tracks active debug view subscriptions: correlationId → (siteId, subscriber). + /// Used to kill debug streams on site disconnection (WP-5). + /// + private readonly Dictionary _debugSubscriptions = new(); + + /// + /// Tracks in-progress deployments: deploymentId → siteId. + /// On central failover, in-progress deployments are treated as failed (WP-5). + /// + private readonly Dictionary _inProgressDeployments = new(); + + public CentralCommunicationActor() + { + // Site registration via heartbeats + Receive(HandleHeartbeat); + + // Connection state changes + Receive(HandleConnectionStateChanged); + + // Site registration command (manual or from discovery) + Receive(HandleRegisterSite); + + // Route enveloped messages to sites + Receive(HandleSiteEnvelope); + } + + private void HandleHeartbeat(HeartbeatMessage heartbeat) + { + // Heartbeats arrive from sites — forward to any interested central actors + // The sender's path tells us the site's communication actor address + if (!_siteSelections.ContainsKey(heartbeat.SiteId)) + { + var senderPath = Sender.Path.ToString(); + _log.Info("Learned site {0} from heartbeat at path {1}", heartbeat.SiteId, senderPath); + } + + // Forward heartbeat to parent/subscribers (central health monitoring) + Context.Parent.Tell(heartbeat); + } + + private void HandleConnectionStateChanged(ConnectionStateChanged msg) + { + if (!msg.IsConnected) + { + _log.Warning("Site {0} disconnected at {1}", msg.SiteId, msg.Timestamp); + + // WP-5: Kill active debug streams for the disconnected site + var toRemove = _debugSubscriptions + .Where(kvp => kvp.Value.SiteId == msg.SiteId) + .ToList(); + + foreach (var kvp in toRemove) + { + _log.Info("Killing debug stream {0} for disconnected site {1}", kvp.Key, msg.SiteId); + kvp.Value.Subscriber.Tell(new DebugStreamTerminated(msg.SiteId, kvp.Key)); + _debugSubscriptions.Remove(kvp.Key); + } + + // WP-5: Mark in-progress deployments as failed + var failedDeployments = _inProgressDeployments + .Where(kvp => kvp.Value == msg.SiteId) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var deploymentId in failedDeployments) + { + _log.Warning("Deployment {0} to site {1} treated as failed due to disconnection", + deploymentId, msg.SiteId); + _inProgressDeployments.Remove(deploymentId); + } + + _siteSelections.Remove(msg.SiteId); + } + else + { + _log.Info("Site {0} connected at {1}", msg.SiteId, msg.Timestamp); + } + } + + private void HandleRegisterSite(RegisterSite msg) + { + var selection = Context.ActorSelection(msg.RemoteActorPath); + _siteSelections[msg.SiteId] = selection; + _log.Info("Registered site {0} at path {1}", msg.SiteId, msg.RemoteActorPath); + } + + private void HandleSiteEnvelope(SiteEnvelope envelope) + { + if (!_siteSelections.TryGetValue(envelope.SiteId, out var siteSelection)) + { + _log.Warning("No known path for site {0}, cannot route message {1}", + envelope.SiteId, envelope.Message.GetType().Name); + + // The Ask will timeout on the caller side — no central buffering (WP-5) + return; + } + + // Track debug subscriptions for cleanup on disconnect + TrackMessageForCleanup(envelope); + + // Forward the inner message to the site, preserving the original sender + // so the site can reply directly to the caller (completing the Ask pattern) + siteSelection.Tell(envelope.Message, Sender); + } + + private void TrackMessageForCleanup(SiteEnvelope envelope) + { + switch (envelope.Message) + { + case Commons.Messages.DebugView.SubscribeDebugViewRequest sub: + _debugSubscriptions[sub.CorrelationId] = (envelope.SiteId, Sender); + break; + + case Commons.Messages.DebugView.UnsubscribeDebugViewRequest unsub: + _debugSubscriptions.Remove(unsub.CorrelationId); + break; + + case Commons.Messages.Deployment.DeployInstanceCommand deploy: + _inProgressDeployments[deploy.DeploymentId] = envelope.SiteId; + break; + } + } + + protected override void PreStart() + { + _log.Info("CentralCommunicationActor started"); + } + + protected override void PostStop() + { + _log.Info("CentralCommunicationActor stopped. In-progress deployments treated as failed (WP-5)."); + // On central failover, all in-progress deployments are failed + _inProgressDeployments.Clear(); + _debugSubscriptions.Clear(); + } +} + +/// +/// Command to register a site's remote communication actor path. +/// +public record RegisterSite(string SiteId, string RemoteActorPath); + +/// +/// Notification sent to debug view subscribers when the stream is terminated +/// due to site disconnection (WP-5). +/// +public record DebugStreamTerminated(string SiteId, string CorrelationId); diff --git a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs new file mode 100644 index 0000000..be71438 --- /dev/null +++ b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs @@ -0,0 +1,212 @@ +using Akka.Actor; +using Akka.Event; +using ScadaLink.Commons.Messages.Artifacts; +using ScadaLink.Commons.Messages.DebugView; +using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Messages.Lifecycle; +using ScadaLink.Commons.Messages.RemoteQuery; + +namespace ScadaLink.Communication.Actors; + +/// +/// Site-side actor that receives messages from central via Akka remoting and routes +/// them to the appropriate local actors. Also sends heartbeats and health reports +/// to central. +/// +/// WP-4: Routes all 8 message patterns to local handlers. +/// +public class SiteCommunicationActor : ReceiveActor, IWithTimers +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly string _siteId; + private readonly CommunicationOptions _options; + + /// + /// Reference to the local Deployment Manager singleton proxy. + /// + private readonly IActorRef _deploymentManagerProxy; + + /// + /// Optional reference to the central communication actor for sending heartbeats/health. + /// Set via RegisterCentral message. + /// + private ActorSelection? _centralSelection; + + /// + /// Local actor references for routing specific message patterns. + /// Populated via registration messages. + /// + private IActorRef? _eventLogHandler; + private IActorRef? _parkedMessageHandler; + private IActorRef? _integrationHandler; + private IActorRef? _artifactHandler; + + public ITimerScheduler Timers { get; set; } = null!; + + public SiteCommunicationActor( + string siteId, + CommunicationOptions options, + IActorRef deploymentManagerProxy) + { + _siteId = siteId; + _options = options; + _deploymentManagerProxy = deploymentManagerProxy; + + // Registration + Receive(HandleRegisterCentral); + Receive(HandleRegisterLocalHandler); + + // Pattern 1: Instance Deployment — forward to Deployment Manager + Receive(msg => + { + _log.Debug("Routing DeployInstanceCommand for {0} to DeploymentManager", msg.InstanceUniqueName); + _deploymentManagerProxy.Forward(msg); + }); + + // Pattern 2: Lifecycle — forward to Deployment Manager + Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); + + // Pattern 3: Artifact Deployment — forward to artifact handler if registered + Receive(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 + Receive(msg => + { + if (_integrationHandler != null) + _integrationHandler.Forward(msg); + else + { + Sender.Tell(new IntegrationCallResponse( + msg.CorrelationId, _siteId, false, null, "Integration handler not available", DateTimeOffset.UtcNow)); + } + }); + + // Pattern 5: Debug View — forward to Deployment Manager (which routes to Instance Actor) + Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); + + // Pattern 7: Remote Queries + Receive(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(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)); + } + }); + + // Internal: send heartbeat tick + Receive(_ => SendHeartbeatToCentral()); + + // Internal: forward health report to central + Receive(msg => + { + _centralSelection?.Tell(msg, Self); + }); + } + + protected override void PreStart() + { + _log.Info("SiteCommunicationActor started for site {0}", _siteId); + + // Schedule periodic heartbeat to central + Timers.StartPeriodicTimer( + "heartbeat", + new SendHeartbeat(), + TimeSpan.FromSeconds(1), // initial delay + _options.TransportHeartbeatInterval); + } + + private void HandleRegisterCentral(RegisterCentralPath msg) + { + _centralSelection = Context.ActorSelection(msg.CentralActorPath); + _log.Info("Registered central communication path: {0}", msg.CentralActorPath); + } + + private void HandleRegisterLocalHandler(RegisterLocalHandler msg) + { + switch (msg.HandlerType) + { + case LocalHandlerType.EventLog: + _eventLogHandler = msg.Handler; + break; + case LocalHandlerType.ParkedMessages: + _parkedMessageHandler = msg.Handler; + break; + case LocalHandlerType.Integration: + _integrationHandler = msg.Handler; + break; + case LocalHandlerType.Artifacts: + _artifactHandler = msg.Handler; + break; + } + + _log.Info("Registered local handler for {0}", msg.HandlerType); + } + + private void SendHeartbeatToCentral() + { + if (_centralSelection == null) + return; + + var hostname = Environment.MachineName; + var heartbeat = new HeartbeatMessage( + _siteId, + hostname, + IsActive: true, + DateTimeOffset.UtcNow); + + _centralSelection.Tell(heartbeat, Self); + } + + // ── Internal messages ── + + internal record SendHeartbeat; +} + +/// +/// Command to register the central communication actor path for outbound messages. +/// +public record RegisterCentralPath(string CentralActorPath); + +/// +/// Command to register a local actor as a handler for a specific message pattern. +/// +public record RegisterLocalHandler(LocalHandlerType HandlerType, IActorRef Handler); + +public enum LocalHandlerType +{ + EventLog, + ParkedMessages, + Integration, + Artifacts +} diff --git a/src/ScadaLink.Communication/CommunicationOptions.cs b/src/ScadaLink.Communication/CommunicationOptions.cs index f68cefc..d13945b 100644 --- a/src/ScadaLink.Communication/CommunicationOptions.cs +++ b/src/ScadaLink.Communication/CommunicationOptions.cs @@ -1,10 +1,35 @@ namespace ScadaLink.Communication; +/// +/// Configuration options for central-site communication, including per-pattern +/// timeouts and transport heartbeat settings. +/// public class CommunicationOptions { + /// Timeout for deployment commands (typically longest due to apply logic). public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2); + + /// Timeout for lifecycle commands (disable, enable, delete). public TimeSpan LifecycleTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Timeout for artifact deployment commands. + public TimeSpan ArtifactDeploymentTimeout { get; set; } = TimeSpan.FromMinutes(1); + + /// Timeout for remote query requests (event logs, parked messages). public TimeSpan QueryTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Timeout for integration call routing. + public TimeSpan IntegrationTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Timeout for debug view subscribe/unsubscribe handshake. + public TimeSpan DebugViewTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// Timeout for health report acknowledgement (fire-and-forget, but bounded). + public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// Akka.Remote transport heartbeat interval. public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5); + + /// Akka.Remote transport failure detection threshold. public TimeSpan TransportFailureThreshold { get; set; } = TimeSpan.FromSeconds(15); } diff --git a/src/ScadaLink.Communication/CommunicationService.cs b/src/ScadaLink.Communication/CommunicationService.cs new file mode 100644 index 0000000..47448d1 --- /dev/null +++ b/src/ScadaLink.Communication/CommunicationService.cs @@ -0,0 +1,152 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.Artifacts; +using ScadaLink.Commons.Messages.DebugView; +using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Messages.Lifecycle; +using ScadaLink.Commons.Messages.RemoteQuery; + +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; + + 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; + } + + private IActorRef GetActor() + { + return _centralCommunicationActor + ?? throw new InvalidOperationException("CommunicationService not initialized. CentralCommunicationActor 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); + } + + // ── 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 6: 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); + } + + // ── Pattern 8: Heartbeat (site→central, Tell) ── + // Heartbeats are received by central, not sent. No method needed here. +} + +/// +/// 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); diff --git a/src/ScadaLink.Communication/ScadaLink.Communication.csproj b/src/ScadaLink.Communication/ScadaLink.Communication.csproj index 049c7d9..a41b756 100644 --- a/src/ScadaLink.Communication/ScadaLink.Communication.csproj +++ b/src/ScadaLink.Communication/ScadaLink.Communication.csproj @@ -8,8 +8,14 @@ + + + + + + diff --git a/src/ScadaLink.Communication/ServiceCollectionExtensions.cs b/src/ScadaLink.Communication/ServiceCollectionExtensions.cs index da65a9e..e6f0021 100644 --- a/src/ScadaLink.Communication/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.Communication/ServiceCollectionExtensions.cs @@ -6,13 +6,18 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddCommunication(this IServiceCollection services) { - // Phase 0: skeleton only + services.AddOptions() + .BindConfiguration("Communication"); + + services.AddSingleton(); + return services; } public static IServiceCollection AddCommunicationActors(this IServiceCollection services) { - // Phase 0: placeholder for Akka actor registration + // Actor registration happens in AkkaHostedService.RegisterCentralActors/RegisterSiteActors. + // This method is a hook for any additional DI registrations needed by the communication actors. return services; } } diff --git a/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs new file mode 100644 index 0000000..b668a4d --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -0,0 +1,476 @@ +using Akka.Actor; +using Akka.Event; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Messages.DataConnection; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.DataConnectionLayer.Actors; + +/// +/// WP-6: Connection actor using Akka.NET Become/Stash pattern for lifecycle state machine. +/// +/// States: +/// - Connecting: stash subscribe/write requests; attempts connection +/// - Connected: unstash and process all requests +/// - Reconnecting: push bad quality for all subscribed tags, stash new requests, +/// fixed-interval reconnect +/// +/// WP-9: Auto-reconnect with bad quality on disconnect. +/// WP-10: Transparent re-subscribe after reconnection. +/// WP-11: Write-back support (synchronous failure to caller, no S&F). +/// WP-12: Tag path resolution with retry. +/// WP-13: Health reporting (connection status + tag resolution counts). +/// WP-14: Subscription lifecycle (register on create, cleanup on stop). +/// +public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly string _connectionName; + private readonly IDataConnection _adapter; + private readonly DataConnectionOptions _options; + + public IStash Stash { get; set; } = null!; + public ITimerScheduler Timers { get; set; } = null!; + + /// + /// Active subscriptions: instanceUniqueName → set of tag paths. + /// + private readonly Dictionary> _subscriptionsByInstance = new(); + + /// + /// Subscription IDs returned by the adapter: tagPath → subscriptionId. + /// + private readonly Dictionary _subscriptionIds = new(); + + /// + /// Tags whose path resolution failed and are awaiting retry. + /// + private readonly HashSet _unresolvedTags = new(); + + /// + /// Subscribers: instanceUniqueName → IActorRef (the Instance Actor). + /// + private readonly Dictionary _subscribers = new(); + + /// + /// Tracks total subscribed and resolved tags for health reporting. + /// + private int _totalSubscribed; + private int _resolvedTags; + + public DataConnectionActor( + string connectionName, + IDataConnection adapter, + DataConnectionOptions options) + { + _connectionName = connectionName; + _adapter = adapter; + _options = options; + } + + protected override void PreStart() + { + _log.Info("DataConnectionActor [{0}] starting in Connecting state", _connectionName); + BecomeConnecting(); + } + + protected override void PostStop() + { + _log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName); + // Clean up the adapter asynchronously + _ = _adapter.DisposeAsync().AsTask(); + } + + protected override void OnReceive(object message) + { + // Default handler — should not be reached due to Become + Unhandled(message); + } + + // ── Connecting State ── + + private void BecomeConnecting() + { + _log.Info("[{0}] Entering Connecting state", _connectionName); + Become(Connecting); + Self.Tell(new AttemptConnect()); + } + + private void Connecting(object message) + { + switch (message) + { + case AttemptConnect: + HandleAttemptConnect(); + break; + case ConnectResult result: + HandleConnectResult(result); + break; + case SubscribeTagsRequest: + case WriteTagRequest: + case UnsubscribeTagsRequest: + Stash.Stash(); + break; + case GetHealthReport: + ReplyWithHealthReport(); + break; + default: + Unhandled(message); + break; + } + } + + // ── Connected State ── + + private void BecomeConnected() + { + _log.Info("[{0}] Entering Connected state", _connectionName); + Become(Connected); + Stash.UnstashAll(); + } + + private void Connected(object message) + { + switch (message) + { + case SubscribeTagsRequest req: + HandleSubscribe(req); + break; + case UnsubscribeTagsRequest req: + HandleUnsubscribe(req); + break; + case WriteTagRequest req: + HandleWrite(req); + break; + case AdapterDisconnected: + HandleDisconnect(); + break; + case RetryTagResolution: + HandleRetryTagResolution(); + break; + case GetHealthReport: + ReplyWithHealthReport(); + break; + default: + Unhandled(message); + break; + } + } + + // ── Reconnecting State ── + + private void BecomeReconnecting() + { + _log.Warning("[{0}] Entering Reconnecting state", _connectionName); + Become(Reconnecting); + + // WP-9: Push bad quality for all subscribed tags on disconnect + PushBadQualityForAllTags(); + + // Schedule reconnect attempt + Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval); + } + + private void Reconnecting(object message) + { + switch (message) + { + case AttemptConnect: + HandleAttemptConnect(); + break; + case ConnectResult result: + HandleReconnectResult(result); + break; + case SubscribeTagsRequest: + case WriteTagRequest: + Stash.Stash(); + break; + case UnsubscribeTagsRequest req: + // Allow unsubscribe even during reconnect (for cleanup on instance stop) + HandleUnsubscribe(req); + break; + case GetHealthReport: + ReplyWithHealthReport(); + break; + default: + Unhandled(message); + break; + } + } + + // ── Connection Management ── + + private void HandleAttemptConnect() + { + _log.Debug("[{0}] Attempting connection...", _connectionName); + var self = Self; + _adapter.ConnectAsync(new Dictionary()).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + return new ConnectResult(true, null); + return new ConnectResult(false, t.Exception?.GetBaseException().Message); + }).PipeTo(self); + } + + private void HandleConnectResult(ConnectResult result) + { + if (result.Success) + { + _log.Info("[{0}] Connection established", _connectionName); + BecomeConnected(); + } + else + { + _log.Warning("[{0}] Connection failed: {1}. Retrying in {2}s", + _connectionName, result.Error, _options.ReconnectInterval.TotalSeconds); + Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval); + } + } + + private void HandleReconnectResult(ConnectResult result) + { + if (result.Success) + { + _log.Info("[{0}] Reconnected successfully", _connectionName); + + // WP-10: Transparent re-subscribe — re-establish all active subscriptions + ReSubscribeAll(); + + BecomeConnected(); + } + else + { + _log.Warning("[{0}] Reconnect failed: {1}. Retrying in {2}s", + _connectionName, result.Error, _options.ReconnectInterval.TotalSeconds); + Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval); + } + } + + private void HandleDisconnect() + { + _log.Warning("[{0}] Adapter reported disconnect", _connectionName); + BecomeReconnecting(); + } + + // ── Subscription Management (WP-14) ── + + private void HandleSubscribe(SubscribeTagsRequest request) + { + _log.Debug("[{0}] Subscribing {1} tags for instance {2}", + _connectionName, request.TagPaths.Count, request.InstanceUniqueName); + + _subscribers[request.InstanceUniqueName] = Sender; + + if (!_subscriptionsByInstance.ContainsKey(request.InstanceUniqueName)) + _subscriptionsByInstance[request.InstanceUniqueName] = new HashSet(); + + var instanceTags = _subscriptionsByInstance[request.InstanceUniqueName]; + var self = Self; + var sender = Sender; + + Task.Run(async () => + { + foreach (var tagPath in request.TagPaths) + { + if (_subscriptionIds.ContainsKey(tagPath)) + { + // Already subscribed — just track for this instance + instanceTags.Add(tagPath); + continue; + } + + try + { + var subId = await _adapter.SubscribeAsync(tagPath, (path, value) => + { + self.Tell(new TagValueReceived(path, value)); + }); + _subscriptionIds[tagPath] = subId; + instanceTags.Add(tagPath); + _totalSubscribed++; + _resolvedTags++; + } + catch (Exception ex) + { + // WP-12: Tag path resolution failure — mark as unresolved, retry later + _unresolvedTags.Add(tagPath); + instanceTags.Add(tagPath); + _totalSubscribed++; + + self.Tell(new TagResolutionFailed(tagPath, ex.Message)); + } + } + + return new SubscribeTagsResponse( + request.CorrelationId, request.InstanceUniqueName, true, null, DateTimeOffset.UtcNow); + }).PipeTo(sender); + + // Start tag resolution retry timer if we have unresolved tags + if (_unresolvedTags.Count > 0) + { + Timers.StartPeriodicTimer( + "tag-resolution-retry", + new RetryTagResolution(), + _options.TagResolutionRetryInterval, + _options.TagResolutionRetryInterval); + } + } + + private void HandleUnsubscribe(UnsubscribeTagsRequest request) + { + _log.Debug("[{0}] Unsubscribing all tags for instance {1}", + _connectionName, request.InstanceUniqueName); + + if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var tags)) + return; + + // WP-14: Cleanup on Instance Actor stop + foreach (var tagPath in tags) + { + // Check if any other instance is still subscribed to this tag + var otherSubscribers = _subscriptionsByInstance + .Where(kvp => kvp.Key != request.InstanceUniqueName && kvp.Value.Contains(tagPath)) + .Any(); + + if (!otherSubscribers && _subscriptionIds.TryGetValue(tagPath, out var subId)) + { + _ = _adapter.UnsubscribeAsync(subId); + _subscriptionIds.Remove(tagPath); + _unresolvedTags.Remove(tagPath); + _totalSubscribed--; + if (!_unresolvedTags.Contains(tagPath)) + _resolvedTags--; + } + } + + _subscriptionsByInstance.Remove(request.InstanceUniqueName); + _subscribers.Remove(request.InstanceUniqueName); + } + + // ── Write Support (WP-11) ── + + private void HandleWrite(WriteTagRequest request) + { + _log.Debug("[{0}] Writing to tag {1}", _connectionName, request.TagPath); + var sender = Sender; + + // WP-11: Write through DCL to device, failure returned synchronously + _adapter.WriteAsync(request.TagPath, request.Value).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var result = t.Result; + return new WriteTagResponse( + request.CorrelationId, result.Success, result.ErrorMessage, DateTimeOffset.UtcNow); + } + return new WriteTagResponse( + request.CorrelationId, false, t.Exception?.GetBaseException().Message, DateTimeOffset.UtcNow); + }).PipeTo(sender); + } + + // ── Tag Resolution Retry (WP-12) ── + + private void HandleRetryTagResolution() + { + if (_unresolvedTags.Count == 0) + { + Timers.Cancel("tag-resolution-retry"); + return; + } + + _log.Debug("[{0}] Retrying resolution for {1} unresolved tags", _connectionName, _unresolvedTags.Count); + + var self = Self; + var toResolve = _unresolvedTags.ToList(); + + foreach (var tagPath in toResolve) + { + _adapter.SubscribeAsync(tagPath, (path, value) => + { + self.Tell(new TagValueReceived(path, value)); + }).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + return new TagResolutionSucceeded(tagPath, t.Result) as object; + return new TagResolutionFailed(tagPath, t.Exception?.GetBaseException().Message ?? "Unknown error"); + }).PipeTo(self); + } + } + + // ── Bad Quality Push (WP-9) ── + + private void PushBadQualityForAllTags() + { + var now = DateTimeOffset.UtcNow; + foreach (var (instanceName, tags) in _subscriptionsByInstance) + { + if (!_subscribers.TryGetValue(instanceName, out var subscriber)) + continue; + + subscriber.Tell(new ConnectionQualityChanged(_connectionName, QualityCode.Bad, now)); + } + } + + // ── Re-subscribe (WP-10) ── + + private void ReSubscribeAll() + { + _log.Info("[{0}] Re-subscribing {1} tags after reconnect", _connectionName, _subscriptionIds.Count); + + var self = Self; + var allTags = _subscriptionIds.Keys.ToList(); + _subscriptionIds.Clear(); + _resolvedTags = 0; + + foreach (var tagPath in allTags) + { + _adapter.SubscribeAsync(tagPath, (path, value) => + { + self.Tell(new TagValueReceived(path, value)); + }).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + return new TagResolutionSucceeded(tagPath, t.Result) as object; + return new TagResolutionFailed(tagPath, t.Exception?.GetBaseException().Message ?? "Unknown error"); + }).PipeTo(self); + } + } + + // ── Health Reporting (WP-13) ── + + private void ReplyWithHealthReport() + { + var status = _adapter.Status; + Sender.Tell(new DataConnectionHealthReport( + _connectionName, status, _totalSubscribed, _resolvedTags, DateTimeOffset.UtcNow)); + } + + // ── Internal message handlers for piped async results ── + + private void HandleTagValueReceived(TagValueReceived msg) + { + // Fan out to all subscribed instances + foreach (var (instanceName, tags) in _subscriptionsByInstance) + { + if (!tags.Contains(msg.TagPath)) + continue; + + if (_subscribers.TryGetValue(instanceName, out var subscriber)) + { + subscriber.Tell(new TagValueUpdate( + _connectionName, msg.TagPath, msg.Value.Value, msg.Value.Quality, msg.Value.Timestamp)); + } + } + } + + // ── Internal messages ── + + internal record AttemptConnect; + internal record ConnectResult(bool Success, string? Error); + internal record AdapterDisconnected; + internal record TagValueReceived(string TagPath, TagValue Value); + internal record TagResolutionFailed(string TagPath, string Error); + internal record TagResolutionSucceeded(string TagPath, string SubscriptionId); + internal record RetryTagResolution; + public record GetHealthReport; +} diff --git a/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionManagerActor.cs new file mode 100644 index 0000000..872ef57 --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionManagerActor.cs @@ -0,0 +1,142 @@ +using Akka.Actor; +using Akka.Event; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Messages.DataConnection; + +namespace ScadaLink.DataConnectionLayer.Actors; + +/// +/// WP-34: Protocol extensibility — manages DataConnectionActor instances. +/// Routes messages to the correct connection actor based on connection name. +/// Adding a new protocol = implement IDataConnection + register with IDataConnectionFactory. +/// +public class DataConnectionManagerActor : ReceiveActor +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly IDataConnectionFactory _factory; + private readonly DataConnectionOptions _options; + private readonly Dictionary _connectionActors = new(); + + public DataConnectionManagerActor( + IDataConnectionFactory factory, + DataConnectionOptions options) + { + _factory = factory; + _options = options; + + Receive(HandleCreateConnection); + Receive(HandleRoute); + Receive(HandleRoute); + Receive(HandleRouteWrite); + Receive(HandleRemoveConnection); + Receive(HandleGetAllHealthReports); + } + + private void HandleCreateConnection(CreateConnectionCommand command) + { + if (_connectionActors.ContainsKey(command.ConnectionName)) + { + _log.Warning("Connection {0} already exists", command.ConnectionName); + return; + } + + // WP-34: Factory creates the correct adapter based on protocol type + var adapter = _factory.Create(command.ProtocolType, command.ConnectionDetails); + + var props = Props.Create(() => new DataConnectionActor( + command.ConnectionName, adapter, _options)); + + var actorRef = Context.ActorOf(props, command.ConnectionName); + _connectionActors[command.ConnectionName] = actorRef; + + _log.Info("Created DataConnectionActor for {0} (protocol={1})", + command.ConnectionName, command.ProtocolType); + } + + private void HandleRoute(SubscribeTagsRequest request) + { + if (_connectionActors.TryGetValue(request.ConnectionName, out var actor)) + actor.Forward(request); + else + { + _log.Warning("No connection actor for {0}", request.ConnectionName); + Sender.Tell(new SubscribeTagsResponse( + request.CorrelationId, request.InstanceUniqueName, false, + $"Unknown connection: {request.ConnectionName}", DateTimeOffset.UtcNow)); + } + } + + private void HandleRoute(UnsubscribeTagsRequest request) + { + if (_connectionActors.TryGetValue(request.ConnectionName, out var actor)) + actor.Forward(request); + else + _log.Warning("No connection actor for {0} during unsubscribe", request.ConnectionName); + } + + private void HandleRouteWrite(WriteTagRequest request) + { + if (_connectionActors.TryGetValue(request.ConnectionName, out var actor)) + actor.Forward(request); + else + { + _log.Warning("No connection actor for {0}", request.ConnectionName); + Sender.Tell(new WriteTagResponse( + request.CorrelationId, false, + $"Unknown connection: {request.ConnectionName}", DateTimeOffset.UtcNow)); + } + } + + private void HandleRemoveConnection(RemoveConnectionCommand command) + { + if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) + { + Context.Stop(actor); + _connectionActors.Remove(command.ConnectionName); + _log.Info("Removed DataConnectionActor for {0}", command.ConnectionName); + } + } + + private void HandleGetAllHealthReports(GetAllHealthReports _) + { + // Forward health report requests to all connection actors + foreach (var actor in _connectionActors.Values) + { + actor.Forward(new DataConnectionActor.GetHealthReport()); + } + } + + /// + /// OneForOneStrategy with Restart for connection actors — a failed connection + /// should restart and attempt reconnection. + /// + protected override SupervisorStrategy SupervisorStrategy() + { + return new OneForOneStrategy( + maxNrOfRetries: 10, + withinTimeRange: TimeSpan.FromMinutes(1), + decider: Decider.From(ex => + { + _log.Warning(ex, "DataConnectionActor threw exception, restarting"); + return Directive.Restart; + })); + } +} + +/// +/// Command to create a new data connection actor for a specific protocol. +/// +public record CreateConnectionCommand( + string ConnectionName, + string ProtocolType, + IDictionary ConnectionDetails); + +/// +/// Command to remove a data connection actor. +/// +public record RemoveConnectionCommand(string ConnectionName); + +/// +/// Request for health reports from all active connections. +/// +public record GetAllHealthReports; diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs new file mode 100644 index 0000000..61608e0 --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs @@ -0,0 +1,120 @@ +namespace ScadaLink.DataConnectionLayer.Adapters; + +/// +/// WP-8: Abstraction over the LmxProxy SDK client for testability. +/// The actual LmxProxyClient SDK lives in a separate repo; this interface +/// defines the contract the adapter depends on. +/// +/// LmxProxy uses gRPC streaming for subscriptions and a session-based model +/// with keep-alive for connection management. +/// +public interface ILmxProxyClient : IAsyncDisposable +{ + /// + /// Opens a session to the LmxProxy server. Returns a session ID. + /// + Task OpenSessionAsync(string host, int port, CancellationToken cancellationToken = default); + + /// + /// Closes the current session. + /// + Task CloseSessionAsync(CancellationToken cancellationToken = default); + + /// + /// Sends a keep-alive to maintain the session. + /// + Task SendKeepAliveAsync(CancellationToken cancellationToken = default); + + bool IsConnected { get; } + string? SessionId { get; } + + /// + /// Subscribes to tag value changes via gRPC streaming. Returns a subscription handle. + /// + Task SubscribeTagAsync( + string tagPath, + Action onValueChanged, + CancellationToken cancellationToken = default); + + Task UnsubscribeTagAsync(string subscriptionHandle, CancellationToken cancellationToken = default); + + Task<(object? Value, DateTime Timestamp, bool IsGood)> ReadTagAsync( + string tagPath, CancellationToken cancellationToken = default); + + Task WriteTagAsync(string tagPath, object? value, CancellationToken cancellationToken = default); +} + +/// +/// Factory for creating ILmxProxyClient instances. +/// +public interface ILmxProxyClientFactory +{ + ILmxProxyClient Create(); +} + +/// +/// Default factory that creates stub LmxProxy clients. +/// In production, this would create real LmxProxy SDK client instances. +/// +public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory +{ + public ILmxProxyClient Create() => new StubLmxProxyClient(); +} + +/// +/// Stub LmxProxy client for development/testing. +/// +internal class StubLmxProxyClient : ILmxProxyClient +{ + public bool IsConnected { get; private set; } + public string? SessionId { get; private set; } + + public Task OpenSessionAsync(string host, int port, CancellationToken cancellationToken = default) + { + SessionId = Guid.NewGuid().ToString(); + IsConnected = true; + return Task.FromResult(SessionId); + } + + public Task CloseSessionAsync(CancellationToken cancellationToken = default) + { + IsConnected = false; + SessionId = null; + return Task.CompletedTask; + } + + public Task SendKeepAliveAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task SubscribeTagAsync( + string tagPath, Action onValueChanged, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Guid.NewGuid().ToString()); + } + + public Task UnsubscribeTagAsync(string subscriptionHandle, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task<(object? Value, DateTime Timestamp, bool IsGood)> ReadTagAsync( + string tagPath, CancellationToken cancellationToken = default) + { + return Task.FromResult<(object?, DateTime, bool)>((null, DateTime.UtcNow, true)); + } + + public Task WriteTagAsync(string tagPath, object? value, CancellationToken cancellationToken = default) + { + return Task.FromResult(true); + } + + public ValueTask DisposeAsync() + { + IsConnected = false; + SessionId = null; + return ValueTask.CompletedTask; + } +} diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs new file mode 100644 index 0000000..7189580 --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -0,0 +1,94 @@ +namespace ScadaLink.DataConnectionLayer.Adapters; + +/// +/// WP-7: Abstraction over OPC UA client library for testability. +/// The real implementation would wrap an OPC UA SDK (e.g., OPC Foundation .NET Standard Library). +/// +public interface IOpcUaClient : IAsyncDisposable +{ + Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default); + Task DisconnectAsync(CancellationToken cancellationToken = default); + bool IsConnected { get; } + + /// + /// Creates a monitored item subscription for a node. Returns a subscription handle. + /// + Task CreateSubscriptionAsync( + string nodeId, + Action onValueChanged, + CancellationToken cancellationToken = default); + + Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default); + + Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync( + string nodeId, CancellationToken cancellationToken = default); + + Task WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default); +} + +/// +/// Factory for creating IOpcUaClient instances. +/// +public interface IOpcUaClientFactory +{ + IOpcUaClient Create(); +} + +/// +/// Default factory that creates stub OPC UA clients. +/// In production, this would create real OPC UA SDK client instances. +/// +public class DefaultOpcUaClientFactory : IOpcUaClientFactory +{ + public IOpcUaClient Create() => new StubOpcUaClient(); +} + +/// +/// Stub OPC UA client for development/testing. A real implementation would +/// wrap the OPC Foundation .NET Standard Library. +/// +internal class StubOpcUaClient : IOpcUaClient +{ + public bool IsConnected { get; private set; } + + public Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default) + { + IsConnected = true; + return Task.CompletedTask; + } + + public Task DisconnectAsync(CancellationToken cancellationToken = default) + { + IsConnected = false; + return Task.CompletedTask; + } + + public Task CreateSubscriptionAsync( + string nodeId, Action onValueChanged, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Guid.NewGuid().ToString()); + } + + public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync( + string nodeId, CancellationToken cancellationToken = default) + { + return Task.FromResult<(object?, DateTime, uint)>((null, DateTime.UtcNow, 0)); + } + + public Task WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default) + { + return Task.FromResult(0); // Good status + } + + public ValueTask DisposeAsync() + { + IsConnected = false; + return ValueTask.CompletedTask; + } +} diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs b/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs new file mode 100644 index 0000000..a4d0222 --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs @@ -0,0 +1,196 @@ +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.DataConnectionLayer.Adapters; + +/// +/// WP-8: LmxProxy adapter implementing IDataConnection. +/// Maps IDataConnection to LmxProxy SDK calls. +/// +/// LmxProxy-specific behavior: +/// - Session-based connection with 30s keep-alive +/// - gRPC streaming for subscriptions +/// - SessionId management (required for all operations) +/// +public class LmxProxyDataConnection : IDataConnection +{ + private readonly ILmxProxyClientFactory _clientFactory; + private readonly ILogger _logger; + private ILmxProxyClient? _client; + private string _host = "localhost"; + private int _port = 5000; + private ConnectionHealth _status = ConnectionHealth.Disconnected; + private Timer? _keepAliveTimer; + + private readonly Dictionary _subscriptionHandles = new(); + + public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger logger) + { + _clientFactory = clientFactory; + _logger = logger; + } + + public ConnectionHealth Status => _status; + + public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) + { + _host = connectionDetails.TryGetValue("Host", out var host) ? host : "localhost"; + if (connectionDetails.TryGetValue("Port", out var portStr) && int.TryParse(portStr, out var port)) + _port = port; + + _status = ConnectionHealth.Connecting; + _client = _clientFactory.Create(); + + var sessionId = await _client.OpenSessionAsync(_host, _port, cancellationToken); + _status = ConnectionHealth.Connected; + + // Start 30s keep-alive timer per design spec + _keepAliveTimer = new Timer( + async _ => await SendKeepAliveAsync(), + null, + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(30)); + + _logger.LogInformation("LmxProxy connected to {Host}:{Port}, sessionId={SessionId}", _host, _port, sessionId); + } + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + _keepAliveTimer?.Dispose(); + _keepAliveTimer = null; + + if (_client != null) + { + await _client.CloseSessionAsync(cancellationToken); + _status = ConnectionHealth.Disconnected; + _logger.LogInformation("LmxProxy disconnected from {Host}:{Port}", _host, _port); + } + } + + public async Task SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var handle = await _client!.SubscribeTagAsync( + tagPath, + (path, value, timestamp, isGood) => + { + var quality = isGood ? QualityCode.Good : QualityCode.Bad; + callback(path, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); + }, + cancellationToken); + + _subscriptionHandles[handle] = tagPath; + return handle; + } + + public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + if (_client != null) + { + await _client.UnsubscribeTagAsync(subscriptionId, cancellationToken); + _subscriptionHandles.Remove(subscriptionId); + } + } + + public async Task ReadAsync(string tagPath, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var (value, timestamp, isGood) = await _client!.ReadTagAsync(tagPath, cancellationToken); + var quality = isGood ? QualityCode.Good : QualityCode.Bad; + + if (!isGood) + return new ReadResult(false, null, "LmxProxy read returned bad quality"); + + return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null); + } + + public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + foreach (var tagPath in tagPaths) + { + results[tagPath] = await ReadAsync(tagPath, cancellationToken); + } + return results; + } + + public async Task WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var success = await _client!.WriteTagAsync(tagPath, value, cancellationToken); + return success + ? new WriteResult(true, null) + : new WriteResult(false, "LmxProxy write failed"); + } + + public async Task> WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + foreach (var (tagPath, value) in values) + { + results[tagPath] = await WriteAsync(tagPath, value, cancellationToken); + } + return results; + } + + public async Task WriteBatchAndWaitAsync( + IDictionary values, string flagPath, object? flagValue, + string responsePath, object? responseValue, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + var allValues = new Dictionary(values) { [flagPath] = flagValue }; + var writeResults = await WriteBatchAsync(allValues, cancellationToken); + + if (writeResults.Values.Any(r => !r.Success)) + return false; + + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var readResult = await ReadAsync(responsePath, cancellationToken); + if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue)) + return true; + + await Task.Delay(100, cancellationToken); + } + + return false; + } + + public async ValueTask DisposeAsync() + { + _keepAliveTimer?.Dispose(); + _keepAliveTimer = null; + + if (_client != null) + { + await _client.DisposeAsync(); + _client = null; + } + _status = ConnectionHealth.Disconnected; + } + + private void EnsureConnected() + { + if (_client == null || !_client.IsConnected) + throw new InvalidOperationException("LmxProxy client is not connected."); + } + + private async Task SendKeepAliveAsync() + { + try + { + if (_client?.IsConnected == true) + await _client.SendKeepAliveAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "LmxProxy keep-alive failed for {Host}:{Port}", _host, _port); + } + } +} diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs new file mode 100644 index 0000000..b527213 --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -0,0 +1,183 @@ +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.DataConnectionLayer.Adapters; + +/// +/// WP-7: OPC UA adapter implementing IDataConnection. +/// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction. +/// +/// OPC UA mapping: +/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature") +/// - Subscribe → MonitoredItem with DataChangeNotification +/// - Read/Write → Read/Write service calls +/// - Quality → OPC UA StatusCode mapping +/// +public class OpcUaDataConnection : IDataConnection +{ + private readonly IOpcUaClientFactory _clientFactory; + private readonly ILogger _logger; + private IOpcUaClient? _client; + private string _endpointUrl = string.Empty; + private ConnectionHealth _status = ConnectionHealth.Disconnected; + + /// + /// Maps subscription IDs to their tag paths for cleanup. + /// + private readonly Dictionary _subscriptionHandles = new(); + + public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger logger) + { + _clientFactory = clientFactory; + _logger = logger; + } + + public ConnectionHealth Status => _status; + + public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) + { + _endpointUrl = connectionDetails.TryGetValue("EndpointUrl", out var url) ? url : "opc.tcp://localhost:4840"; + _status = ConnectionHealth.Connecting; + + _client = _clientFactory.Create(); + await _client.ConnectAsync(_endpointUrl, cancellationToken); + + _status = ConnectionHealth.Connected; + _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl); + } + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + if (_client != null) + { + await _client.DisconnectAsync(cancellationToken); + _status = ConnectionHealth.Disconnected; + _logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl); + } + } + + public async Task SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var subscriptionId = await _client!.CreateSubscriptionAsync( + tagPath, + (nodeId, value, timestamp, statusCode) => + { + var quality = MapStatusCode(statusCode); + callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); + }, + cancellationToken); + + _subscriptionHandles[subscriptionId] = tagPath; + return subscriptionId; + } + + public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + if (_client != null) + { + await _client.RemoveSubscriptionAsync(subscriptionId, cancellationToken); + _subscriptionHandles.Remove(subscriptionId); + } + } + + public async Task ReadAsync(string tagPath, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken); + var quality = MapStatusCode(statusCode); + + if (quality == QualityCode.Bad) + return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}"); + + return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null); + } + + public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + foreach (var tagPath in tagPaths) + { + results[tagPath] = await ReadAsync(tagPath, cancellationToken); + } + return results; + } + + public async Task WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var statusCode = await _client!.WriteValueAsync(tagPath, value, cancellationToken); + if (statusCode != 0) + return new WriteResult(false, $"OPC UA write failed with status: 0x{statusCode:X8}"); + + return new WriteResult(true, null); + } + + public async Task> WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + foreach (var (tagPath, value) in values) + { + results[tagPath] = await WriteAsync(tagPath, value, cancellationToken); + } + return results; + } + + public async Task WriteBatchAndWaitAsync( + IDictionary values, string flagPath, object? flagValue, + string responsePath, object? responseValue, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + // Write all values including the flag + var allValues = new Dictionary(values) { [flagPath] = flagValue }; + var writeResults = await WriteBatchAsync(allValues, cancellationToken); + + if (writeResults.Values.Any(r => !r.Success)) + return false; + + // Poll for response value within timeout + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var readResult = await ReadAsync(responsePath, cancellationToken); + if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue)) + return true; + + await Task.Delay(100, cancellationToken); + } + + return false; + } + + public async ValueTask DisposeAsync() + { + if (_client != null) + { + await _client.DisposeAsync(); + _client = null; + } + _status = ConnectionHealth.Disconnected; + } + + private void EnsureConnected() + { + if (_client == null || !_client.IsConnected) + throw new InvalidOperationException("OPC UA client is not connected."); + } + + /// + /// Maps OPC UA StatusCode to QualityCode. + /// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain. + /// + private static QualityCode MapStatusCode(uint statusCode) + { + if (statusCode == 0) return QualityCode.Good; + if ((statusCode & 0x80000000) != 0) return QualityCode.Bad; + return QualityCode.Uncertain; + } +} diff --git a/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs b/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs new file mode 100644 index 0000000..7aa30ef --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.DataConnectionLayer.Adapters; + +namespace ScadaLink.DataConnectionLayer; + +/// +/// WP-34: Default factory that resolves protocol type strings to IDataConnection adapters. +/// Protocol extensibility: register new adapters via the constructor or AddAdapter method. +/// +public class DataConnectionFactory : IDataConnectionFactory +{ + private readonly Dictionary, IDataConnection>> _factories = new(StringComparer.OrdinalIgnoreCase); + private readonly ILoggerFactory _loggerFactory; + + public DataConnectionFactory(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + + // Register built-in protocols + RegisterAdapter("OpcUa", details => new OpcUaDataConnection( + new DefaultOpcUaClientFactory(), _loggerFactory.CreateLogger())); + RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection( + new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger())); + } + + /// + /// Registers a new protocol adapter factory. This is the extension point + /// for adding new protocols without modifying existing code. + /// + public void RegisterAdapter(string protocolType, Func, IDataConnection> factory) + { + _factories[protocolType] = factory; + } + + public IDataConnection Create(string protocolType, IDictionary connectionDetails) + { + if (!_factories.TryGetValue(protocolType, out var factory)) + throw new ArgumentException($"Unknown protocol type: {protocolType}. Registered protocols: {string.Join(", ", _factories.Keys)}"); + + return factory(connectionDetails); + } +} diff --git a/src/ScadaLink.DataConnectionLayer/DataConnectionOptions.cs b/src/ScadaLink.DataConnectionLayer/DataConnectionOptions.cs index 423d9aa..02073e0 100644 --- a/src/ScadaLink.DataConnectionLayer/DataConnectionOptions.cs +++ b/src/ScadaLink.DataConnectionLayer/DataConnectionOptions.cs @@ -1,8 +1,19 @@ namespace ScadaLink.DataConnectionLayer; +/// +/// Configuration options for the Data Connection Layer. +/// public class DataConnectionOptions { + /// Fixed interval between reconnect attempts after disconnect. public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5); + + /// Interval for retrying failed tag path resolution. public TimeSpan TagResolutionRetryInterval { get; set; } = TimeSpan.FromSeconds(10); + + /// Timeout for synchronous write operations to devices. public TimeSpan WriteTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// LmxProxy keep-alive interval for gRPC sessions. + public TimeSpan LmxProxyKeepAliveInterval { get; set; } = TimeSpan.FromSeconds(30); } diff --git a/src/ScadaLink.DataConnectionLayer/IDataConnectionFactory.cs b/src/ScadaLink.DataConnectionLayer/IDataConnectionFactory.cs new file mode 100644 index 0000000..12e9fed --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/IDataConnectionFactory.cs @@ -0,0 +1,18 @@ +using ScadaLink.Commons.Interfaces.Protocol; + +namespace ScadaLink.DataConnectionLayer; + +/// +/// WP-34: Factory for creating IDataConnection adapters based on protocol type. +/// Adding a new protocol = implement IDataConnection + register with the factory. +/// +public interface IDataConnectionFactory +{ + /// + /// Creates an IDataConnection adapter for the specified protocol type. + /// + /// Protocol identifier (e.g., "OpcUa", "LmxProxy"). + /// Protocol-specific connection parameters. + /// A configured but not yet connected IDataConnection instance. + IDataConnection Create(string protocolType, IDictionary connectionDetails); +} diff --git a/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj b/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj index 049c7d9..adb43f8 100644 --- a/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj +++ b/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj @@ -8,8 +8,13 @@ + + + + + diff --git a/src/ScadaLink.DataConnectionLayer/ServiceCollectionExtensions.cs b/src/ScadaLink.DataConnectionLayer/ServiceCollectionExtensions.cs index 4c04de9..28fd163 100644 --- a/src/ScadaLink.DataConnectionLayer/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.DataConnectionLayer/ServiceCollectionExtensions.cs @@ -6,13 +6,19 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services) { - // Phase 0: skeleton only + services.AddOptions() + .BindConfiguration("DataConnectionLayer"); + + // WP-34: Register the factory for protocol extensibility + services.AddSingleton(); + return services; } public static IServiceCollection AddDataConnectionLayerActors(this IServiceCollection services) { - // Phase 0: placeholder for Akka actor registration + // Actor registration happens in AkkaHostedService or SiteCommunicationActor setup. + // DataConnectionManagerActor and DataConnectionActor instances are created by the actor system. return services; } } diff --git a/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs b/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs new file mode 100644 index 0000000..67518e4 --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs @@ -0,0 +1,134 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.Health; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Central-side aggregator that receives health reports from all sites, +/// tracks latest metrics in memory, and detects offline sites. +/// No persistence — display-only for Central UI consumption. +/// +public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregator +{ + private readonly ConcurrentDictionary _siteStates = new(); + private readonly HealthMonitoringOptions _options; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public CentralHealthAggregator( + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) + { + _options = options.Value; + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Process an incoming health report from a site. + /// Only replaces stored state if incoming sequence number is greater than last received. + /// Auto-marks previously offline sites as online. + /// + public void ProcessReport(SiteHealthReport report) + { + var now = _timeProvider.GetUtcNow(); + + _siteStates.AddOrUpdate( + report.SiteId, + _ => + { + _logger.LogInformation("Site {SiteId} registered with sequence #{Seq}", report.SiteId, report.SequenceNumber); + return new SiteHealthState + { + SiteId = report.SiteId, + LatestReport = report, + LastReportReceivedAt = now, + LastSequenceNumber = report.SequenceNumber, + IsOnline = true + }; + }, + (_, existing) => + { + if (report.SequenceNumber <= existing.LastSequenceNumber) + { + _logger.LogDebug( + "Rejecting stale report from site {SiteId}: seq {Incoming} <= {Last}", + report.SiteId, report.SequenceNumber, existing.LastSequenceNumber); + return existing; + } + + var wasOffline = !existing.IsOnline; + existing.LatestReport = report; + existing.LastReportReceivedAt = now; + existing.LastSequenceNumber = report.SequenceNumber; + existing.IsOnline = true; + + if (wasOffline) + { + _logger.LogInformation("Site {SiteId} is back online (seq #{Seq})", report.SiteId, report.SequenceNumber); + } + + return existing; + }); + } + + /// + /// Get the current health state for all known sites. + /// + public IReadOnlyDictionary GetAllSiteStates() + { + return new Dictionary(_siteStates); + } + + /// + /// Get the current health state for a specific site, or null if unknown. + /// + public SiteHealthState? GetSiteState(string siteId) + { + _siteStates.TryGetValue(siteId, out var state); + return state; + } + + /// + /// Background task that periodically checks for offline sites. + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "Central health aggregator started, offline timeout {Timeout}s", + _options.OfflineTimeout.TotalSeconds); + + // Check at half the offline timeout interval for timely detection + var checkInterval = TimeSpan.FromMilliseconds(_options.OfflineTimeout.TotalMilliseconds / 2); + using var timer = new PeriodicTimer(checkInterval); + + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + CheckForOfflineSites(); + } + } + + internal void CheckForOfflineSites() + { + var now = _timeProvider.GetUtcNow(); + + foreach (var kvp in _siteStates) + { + var state = kvp.Value; + if (!state.IsOnline) continue; + + var elapsed = now - state.LastReportReceivedAt; + if (elapsed > _options.OfflineTimeout) + { + state.IsOnline = false; + _logger.LogWarning( + "Site {SiteId} marked offline — no report for {Elapsed}s (timeout: {Timeout}s)", + state.SiteId, elapsed.TotalSeconds, _options.OfflineTimeout.TotalSeconds); + } + } + } +} diff --git a/src/ScadaLink.HealthMonitoring/HealthReportSender.cs b/src/ScadaLink.HealthMonitoring/HealthReportSender.cs new file mode 100644 index 0000000..303934e --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/HealthReportSender.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.Health; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Periodically collects a SiteHealthReport and sends it to central via Akka remoting. +/// Sequence numbers are monotonic, starting at 1, and reset on service restart. +/// +public class HealthReportSender : BackgroundService +{ + private readonly ISiteHealthCollector _collector; + private readonly IHealthReportTransport _transport; + private readonly HealthMonitoringOptions _options; + private readonly ILogger _logger; + private readonly string _siteId; + private long _sequenceNumber; + + public HealthReportSender( + ISiteHealthCollector collector, + IHealthReportTransport transport, + IOptions options, + ILogger logger, + ISiteIdentityProvider siteIdentityProvider) + { + _collector = collector; + _transport = transport; + _options = options.Value; + _logger = logger; + _siteId = siteIdentityProvider.SiteId; + } + + /// + /// Current sequence number (for testing). + /// + public long CurrentSequenceNumber => Interlocked.Read(ref _sequenceNumber); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "Health report sender starting for site {SiteId}, interval {Interval}s", + _siteId, _options.ReportInterval.TotalSeconds); + + using var timer = new PeriodicTimer(_options.ReportInterval); + + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + var seq = Interlocked.Increment(ref _sequenceNumber); + var report = _collector.CollectReport(_siteId); + + // Replace the placeholder sequence number with our monotonic one + var reportWithSeq = report with { SequenceNumber = seq }; + + _transport.Send(reportWithSeq); + + _logger.LogDebug("Sent health report #{Seq} for site {SiteId}", seq, _siteId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send health report for site {SiteId}", _siteId); + // Continue sending — don't let a single failure stop reporting + } + } + } +} diff --git a/src/ScadaLink.HealthMonitoring/ICentralHealthAggregator.cs b/src/ScadaLink.HealthMonitoring/ICentralHealthAggregator.cs new file mode 100644 index 0000000..4335caa --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/ICentralHealthAggregator.cs @@ -0,0 +1,14 @@ +using ScadaLink.Commons.Messages.Health; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Interface for central-side health aggregation. +/// Consumed by Central UI to display site health dashboards. +/// +public interface ICentralHealthAggregator +{ + void ProcessReport(SiteHealthReport report); + IReadOnlyDictionary GetAllSiteStates(); + SiteHealthState? GetSiteState(string siteId); +} diff --git a/src/ScadaLink.HealthMonitoring/IHealthReportTransport.cs b/src/ScadaLink.HealthMonitoring/IHealthReportTransport.cs new file mode 100644 index 0000000..1079e11 --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/IHealthReportTransport.cs @@ -0,0 +1,12 @@ +using ScadaLink.Commons.Messages.Health; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Abstraction for sending health reports to central. +/// In production, implemented via Akka remoting (Tell, fire-and-forget). +/// +public interface IHealthReportTransport +{ + void Send(SiteHealthReport report); +} diff --git a/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs new file mode 100644 index 0000000..95b9128 --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs @@ -0,0 +1,19 @@ +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Interface for site-side health metric collection. +/// Consumed by Site Runtime actors to report errors, and by DCL to report connection health. +/// +public interface ISiteHealthCollector +{ + void IncrementScriptError(); + void IncrementAlarmError(); + void IncrementDeadLetter(); + void UpdateConnectionHealth(string connectionName, ConnectionHealth health); + void RemoveConnection(string connectionName); + void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved); + SiteHealthReport CollectReport(string siteId); +} diff --git a/src/ScadaLink.HealthMonitoring/ISiteIdentityProvider.cs b/src/ScadaLink.HealthMonitoring/ISiteIdentityProvider.cs new file mode 100644 index 0000000..d77c89a --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/ISiteIdentityProvider.cs @@ -0,0 +1,10 @@ +namespace ScadaLink.HealthMonitoring; + +/// +/// Provides the identity of the current site. +/// Implemented by the Host component to supply configuration-driven site ID. +/// +public interface ISiteIdentityProvider +{ + string SiteId { get; } +} diff --git a/src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj b/src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj index 049c7d9..04bf1bf 100644 --- a/src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj +++ b/src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj @@ -9,6 +9,8 @@ + + @@ -16,4 +18,8 @@ + + + + diff --git a/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs b/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs index b7c6d03..64353c0 100644 --- a/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs @@ -4,15 +4,30 @@ namespace ScadaLink.HealthMonitoring; public static class ServiceCollectionExtensions { + /// + /// Register site-side health monitoring services. + /// public static IServiceCollection AddHealthMonitoring(this IServiceCollection services) { - // Phase 0: skeleton only + services.AddSingleton(); + services.AddHostedService(); + return services; + } + + /// + /// Register central-side health aggregation services. + /// + public static IServiceCollection AddCentralHealthAggregation(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); return services; } public static IServiceCollection AddHealthMonitoringActors(this IServiceCollection services) { - // Phase 0: placeholder for Akka actor registration + // Placeholder for Akka actor registration (Phase 4+) return services; } } diff --git a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs new file mode 100644 index 0000000..d245b08 --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs @@ -0,0 +1,101 @@ +using System.Collections.Concurrent; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Collects health metrics from all site subsystems. +/// Thread-safe: counters use Interlocked operations, connection/tag data uses ConcurrentDictionary. +/// +public class SiteHealthCollector : ISiteHealthCollector +{ + private int _scriptErrorCount; + private int _alarmErrorCount; + private int _deadLetterCount; + private readonly ConcurrentDictionary _connectionStatuses = new(); + private readonly ConcurrentDictionary _tagResolutionCounts = new(); + + /// + /// Increment the script error counter. Covers unhandled exceptions, + /// timeouts, and recursion limit violations. + /// + public void IncrementScriptError() + { + Interlocked.Increment(ref _scriptErrorCount); + } + + /// + /// Increment the alarm evaluation error counter. + /// + public void IncrementAlarmError() + { + Interlocked.Increment(ref _alarmErrorCount); + } + + /// + /// Increment the dead letter counter for this reporting interval. + /// + public void IncrementDeadLetter() + { + Interlocked.Increment(ref _deadLetterCount); + } + + /// + /// Update the health status for a named data connection. + /// Called by DCL when connection state changes. + /// + public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) + { + _connectionStatuses[connectionName] = health; + } + + /// + /// Remove a connection from tracking (e.g., on connection disposal). + /// + public void RemoveConnection(string connectionName) + { + _connectionStatuses.TryRemove(connectionName, out _); + _tagResolutionCounts.TryRemove(connectionName, out _); + } + + /// + /// Update tag resolution counts for a named data connection. + /// Called by DCL after tag resolution attempts. + /// + public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) + { + _tagResolutionCounts[connectionName] = new TagResolutionStatus(totalSubscribed, successfullyResolved); + } + + /// + /// Collect the current health report for the site and reset interval counters. + /// Connection statuses and tag resolution counts are NOT reset (they reflect current state). + /// Script errors, alarm errors, and dead letters ARE reset (they are per-interval counts). + /// + public SiteHealthReport CollectReport(string siteId) + { + // Atomically read and reset the counters + var scriptErrors = Interlocked.Exchange(ref _scriptErrorCount, 0); + var alarmErrors = Interlocked.Exchange(ref _alarmErrorCount, 0); + var deadLetters = Interlocked.Exchange(ref _deadLetterCount, 0); + + // Snapshot current connection and tag resolution state + var connectionStatuses = new Dictionary(_connectionStatuses); + var tagResolution = new Dictionary(_tagResolutionCounts); + + // S&F buffer depth: placeholder (Phase 3C) + var sfBufferDepths = new Dictionary(); + + return new SiteHealthReport( + SiteId: siteId, + SequenceNumber: 0, // Caller (HealthReportSender) assigns the sequence number + ReportTimestamp: DateTimeOffset.UtcNow, + DataConnectionStatuses: connectionStatuses, + TagResolutionCounts: tagResolution, + ScriptErrorCount: scriptErrors, + AlarmEvaluationErrorCount: alarmErrors, + StoreAndForwardBufferDepths: sfBufferDepths, + DeadLetterCount: deadLetters); + } +} diff --git a/src/ScadaLink.HealthMonitoring/SiteHealthState.cs b/src/ScadaLink.HealthMonitoring/SiteHealthState.cs new file mode 100644 index 0000000..bd23cea --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/SiteHealthState.cs @@ -0,0 +1,15 @@ +using ScadaLink.Commons.Messages.Health; + +namespace ScadaLink.HealthMonitoring; + +/// +/// In-memory state for a single site's health, stored by the central aggregator. +/// +public class SiteHealthState +{ + public required string SiteId { get; init; } + public SiteHealthReport LatestReport { get; set; } = null!; + public DateTimeOffset LastReportReceivedAt { get; set; } + public long LastSequenceNumber { get; set; } + public bool IsOnline { get; set; } +} diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index f421adf..e0f18b0 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -4,10 +4,14 @@ using Akka.Cluster.Tools.Singleton; using Akka.Configuration; using Microsoft.Extensions.Options; using ScadaLink.ClusterInfrastructure; +using ScadaLink.Communication; +using ScadaLink.Communication.Actors; using ScadaLink.Host.Actors; using ScadaLink.SiteRuntime; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; +using ScadaLink.SiteRuntime.Streaming; namespace ScadaLink.Host.Actors; @@ -15,12 +19,15 @@ namespace ScadaLink.Host.Actors; /// Hosted service that manages the Akka.NET actor system lifecycle. /// Creates the actor system on start, registers actors, and triggers /// CoordinatedShutdown on stop. +/// +/// WP-3: Transport heartbeat is explicitly configured in HOCON from CommunicationOptions. /// public class AkkaHostedService : IHostedService { private readonly IServiceProvider _serviceProvider; private readonly NodeOptions _nodeOptions; private readonly ClusterOptions _clusterOptions; + private readonly CommunicationOptions _communicationOptions; private readonly ILogger _logger; private ActorSystem? _actorSystem; @@ -28,11 +35,13 @@ public class AkkaHostedService : IHostedService IServiceProvider serviceProvider, IOptions nodeOptions, IOptions clusterOptions, + IOptions communicationOptions, ILogger logger) { _serviceProvider = serviceProvider; _nodeOptions = nodeOptions.Value; _clusterOptions = clusterOptions.Value; + _communicationOptions = communicationOptions.Value; _logger = logger; } @@ -50,6 +59,10 @@ public class AkkaHostedService : IHostedService var roles = BuildRoles(); var rolesStr = string.Join(",", roles.Select(r => $"\"{r}\"")); + // WP-3: Transport heartbeat explicitly configured from CommunicationOptions (not framework defaults) + var transportHeartbeatSec = _communicationOptions.TransportHeartbeatInterval.TotalSeconds; + var transportFailureSec = _communicationOptions.TransportFailureThreshold.TotalSeconds; + var hocon = $@" akka {{ actor {{ @@ -60,6 +73,10 @@ akka {{ hostname = ""{_nodeOptions.NodeHostname}"" port = {_nodeOptions.RemotingPort} }} + transport-failure-detector {{ + heartbeat-interval = {transportHeartbeatSec:F0}s + acceptable-heartbeat-pause = {transportFailureSec:F0}s + }} }} cluster {{ seed-nodes = [{seedNodesStr}] @@ -87,11 +104,14 @@ akka {{ _actorSystem = ActorSystem.Create("scadalink", config); _logger.LogInformation( - "Akka.NET actor system 'scadalink' started. Role={Role}, Roles={Roles}, Hostname={Hostname}, Port={Port}", + "Akka.NET actor system 'scadalink' started. Role={Role}, Roles={Roles}, Hostname={Hostname}, Port={Port}, " + + "TransportHeartbeat={TransportHeartbeat}s, TransportFailure={TransportFailure}s", _nodeOptions.Role, string.Join(", ", roles), _nodeOptions.NodeHostname, - _nodeOptions.RemotingPort); + _nodeOptions.RemotingPort, + transportHeartbeatSec, + transportFailureSec); // Register the dead letter monitor actor var loggerFactory = _serviceProvider.GetRequiredService(); @@ -100,8 +120,12 @@ akka {{ Props.Create(() => new DeadLetterMonitorActor(dlmLogger)), "dead-letter-monitor"); - // For site nodes, register the Deployment Manager as a cluster singleton - if (_nodeOptions.Role.Equals("Site", StringComparison.OrdinalIgnoreCase)) + // Register role-specific actors + if (_nodeOptions.Role.Equals("Central", StringComparison.OrdinalIgnoreCase)) + { + RegisterCentralActors(); + } + else if (_nodeOptions.Role.Equals("Site", StringComparison.OrdinalIgnoreCase)) { RegisterSiteActors(); } @@ -138,7 +162,25 @@ akka {{ } /// - /// Registers site-specific actors including the Deployment Manager cluster singleton. + /// Registers central-side actors including the CentralCommunicationActor. + /// WP-4: Central communication actor routes all 8 message patterns to sites. + /// + private void RegisterCentralActors() + { + var centralCommActor = _actorSystem!.ActorOf( + Props.Create(() => new CentralCommunicationActor()), + "central-communication"); + + // Wire up the CommunicationService with the actor reference + var commService = _serviceProvider.GetService(); + commService?.SetCommunicationActor(centralCommActor); + + _logger.LogInformation("Central actors registered. CentralCommunicationActor created."); + } + + /// + /// Registers site-specific actors including the Deployment Manager cluster singleton + /// and the SiteCommunicationActor. /// The singleton is scoped to the site-specific cluster role so it runs on exactly /// one node within this site's cluster. /// @@ -146,6 +188,9 @@ akka {{ { var siteRole = $"site-{_nodeOptions.SiteId}"; var storage = _serviceProvider.GetRequiredService(); + var compilationService = _serviceProvider.GetRequiredService(); + var sharedScriptLibrary = _serviceProvider.GetRequiredService(); + var streamManager = _serviceProvider.GetService(); var siteRuntimeOptionsValue = _serviceProvider.GetService>()?.Value ?? new SiteRuntimeOptions(); var dmLogger = _serviceProvider.GetRequiredService() @@ -155,6 +200,9 @@ akka {{ var singletonProps = ClusterSingletonManager.Props( singletonProps: Props.Create(() => new DeploymentManagerActor( storage, + compilationService, + sharedScriptLibrary, + streamManager, siteRuntimeOptionsValue, dmLogger)), terminationMessage: PoisonPill.Instance, @@ -171,10 +219,18 @@ akka {{ .WithRole(siteRole) .WithSingletonName("deployment-manager")); - _actorSystem.ActorOf(proxyProps, "deployment-manager-proxy"); + var dmProxy = _actorSystem.ActorOf(proxyProps, "deployment-manager-proxy"); + + // WP-4: Create SiteCommunicationActor for receiving messages from central + _actorSystem.ActorOf( + Props.Create(() => new SiteCommunicationActor( + _nodeOptions.SiteId!, + _communicationOptions, + dmProxy)), + "site-communication"); _logger.LogInformation( - "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}", + "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.", siteRole); } } diff --git a/src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs b/src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs new file mode 100644 index 0000000..07cd7ed --- /dev/null +++ b/src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs @@ -0,0 +1,120 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ScadaLink.SiteEventLogging; + +/// +/// Background service that periodically purges old events from the SQLite event log. +/// Enforces both time-based retention (default 30 days) and storage cap (default 1GB). +/// Runs on a background thread and does not block event recording. +/// +public class EventLogPurgeService : BackgroundService +{ + private readonly SiteEventLogger _eventLogger; + private readonly SiteEventLogOptions _options; + private readonly ILogger _logger; + + public EventLogPurgeService( + ISiteEventLogger eventLogger, + IOptions options, + ILogger logger) + { + // We need the concrete type to access the connection + _eventLogger = (SiteEventLogger)eventLogger; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "Event log purge service started — retention: {Days} days, cap: {Cap} MB, interval: {Interval}", + _options.RetentionDays, _options.MaxStorageMb, _options.PurgeInterval); + + using var timer = new PeriodicTimer(_options.PurgeInterval); + + // Run an initial purge on startup + RunPurge(); + + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + RunPurge(); + } + } + + internal void RunPurge() + { + try + { + PurgeByRetention(); + PurgeByStorageCap(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during event log purge"); + } + } + + private void PurgeByRetention() + { + var cutoff = DateTimeOffset.UtcNow.AddDays(-_options.RetentionDays).ToString("o"); + + using var cmd = _eventLogger.Connection.CreateCommand(); + cmd.CommandText = "DELETE FROM site_events WHERE timestamp < $cutoff"; + cmd.Parameters.AddWithValue("$cutoff", cutoff); + var deleted = cmd.ExecuteNonQuery(); + + if (deleted > 0) + { + _logger.LogInformation("Purged {Count} events older than {Days} days", deleted, _options.RetentionDays); + } + } + + private void PurgeByStorageCap() + { + var currentSizeBytes = GetDatabaseSizeBytes(); + var capBytes = (long)_options.MaxStorageMb * 1024 * 1024; + + if (currentSizeBytes <= capBytes) + return; + + _logger.LogWarning( + "Event log size {Size:F1} MB exceeds cap {Cap} MB — purging oldest events", + currentSizeBytes / (1024.0 * 1024.0), _options.MaxStorageMb); + + // Delete oldest events in batches until under the cap + while (currentSizeBytes > capBytes) + { + using var cmd = _eventLogger.Connection.CreateCommand(); + cmd.CommandText = """ + DELETE FROM site_events WHERE id IN ( + SELECT id FROM site_events ORDER BY id ASC LIMIT 1000 + ) + """; + var deleted = cmd.ExecuteNonQuery(); + if (deleted == 0) break; + + // Reclaim space + using var vacuumCmd = _eventLogger.Connection.CreateCommand(); + vacuumCmd.CommandText = "PRAGMA incremental_vacuum"; + vacuumCmd.ExecuteNonQuery(); + + currentSizeBytes = GetDatabaseSizeBytes(); + } + } + + internal long GetDatabaseSizeBytes() + { + using var pageCountCmd = _eventLogger.Connection.CreateCommand(); + pageCountCmd.CommandText = "PRAGMA page_count"; + var pageCount = (long)pageCountCmd.ExecuteScalar()!; + + using var pageSizeCmd = _eventLogger.Connection.CreateCommand(); + pageSizeCmd.CommandText = "PRAGMA page_size"; + var pageSize = (long)pageSizeCmd.ExecuteScalar()!; + + return pageCount * pageSize; + } +} diff --git a/src/ScadaLink.SiteEventLogging/EventLogQueryService.cs b/src/ScadaLink.SiteEventLogging/EventLogQueryService.cs new file mode 100644 index 0000000..2b129bc --- /dev/null +++ b/src/ScadaLink.SiteEventLogging/EventLogQueryService.cs @@ -0,0 +1,146 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.RemoteQuery; + +namespace ScadaLink.SiteEventLogging; + +/// +/// Processes event log queries locally against SQLite. +/// Supports filtering by event_type, time range, instance_id, severity, +/// and keyword search (LIKE on message and source). +/// Uses keyset pagination with continuation token (last event ID). +/// +public class EventLogQueryService : IEventLogQueryService +{ + private readonly SiteEventLogger _eventLogger; + private readonly SiteEventLogOptions _options; + private readonly ILogger _logger; + + public EventLogQueryService( + ISiteEventLogger eventLogger, + IOptions options, + ILogger logger) + { + _eventLogger = (SiteEventLogger)eventLogger; + _options = options.Value; + _logger = logger; + } + + public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request) + { + try + { + var pageSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize; + + using var cmd = _eventLogger.Connection.CreateCommand(); + var whereClauses = new List(); + var parameters = new List(); + + // Keyset pagination: only return events with id > continuation token + if (request.ContinuationToken.HasValue) + { + whereClauses.Add("id > $afterId"); + parameters.Add(new SqliteParameter("$afterId", request.ContinuationToken.Value)); + } + + if (request.From.HasValue) + { + whereClauses.Add("timestamp >= $from"); + parameters.Add(new SqliteParameter("$from", request.From.Value.ToString("o"))); + } + + if (request.To.HasValue) + { + whereClauses.Add("timestamp <= $to"); + parameters.Add(new SqliteParameter("$to", request.To.Value.ToString("o"))); + } + + if (!string.IsNullOrWhiteSpace(request.EventType)) + { + whereClauses.Add("event_type = $eventType"); + parameters.Add(new SqliteParameter("$eventType", request.EventType)); + } + + if (!string.IsNullOrWhiteSpace(request.Severity)) + { + whereClauses.Add("severity = $severity"); + parameters.Add(new SqliteParameter("$severity", request.Severity)); + } + + if (!string.IsNullOrWhiteSpace(request.InstanceId)) + { + whereClauses.Add("instance_id = $instanceId"); + parameters.Add(new SqliteParameter("$instanceId", request.InstanceId)); + } + + if (!string.IsNullOrWhiteSpace(request.KeywordFilter)) + { + whereClauses.Add("(message LIKE $keyword OR source LIKE $keyword)"); + parameters.Add(new SqliteParameter("$keyword", $"%{request.KeywordFilter}%")); + } + + var whereClause = whereClauses.Count > 0 + ? "WHERE " + string.Join(" AND ", whereClauses) + : ""; + + // Fetch pageSize + 1 to determine if there are more results + cmd.CommandText = $""" + SELECT id, timestamp, event_type, severity, instance_id, source, message, details + FROM site_events + {whereClause} + ORDER BY id ASC + LIMIT $limit + """; + cmd.Parameters.AddWithValue("$limit", pageSize + 1); + foreach (var p in parameters) + cmd.Parameters.Add(p); + + var entries = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + entries.Add(new EventLogEntry( + Id: reader.GetInt64(0), + Timestamp: DateTimeOffset.Parse(reader.GetString(1)), + EventType: reader.GetString(2), + Severity: reader.GetString(3), + InstanceId: reader.IsDBNull(4) ? null : reader.GetString(4), + Source: reader.GetString(5), + Message: reader.GetString(6), + Details: reader.IsDBNull(7) ? null : reader.GetString(7))); + } + + var hasMore = entries.Count > pageSize; + if (hasMore) + { + entries.RemoveAt(entries.Count - 1); + } + + var continuationToken = entries.Count > 0 ? entries[^1].Id : (long?)null; + + return new EventLogQueryResponse( + CorrelationId: request.CorrelationId, + SiteId: request.SiteId, + Entries: entries, + ContinuationToken: continuationToken, + HasMore: hasMore, + Success: true, + ErrorMessage: null, + Timestamp: DateTimeOffset.UtcNow); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to execute event log query: {CorrelationId}", request.CorrelationId); + return new EventLogQueryResponse( + CorrelationId: request.CorrelationId, + SiteId: request.SiteId, + Entries: [], + ContinuationToken: null, + HasMore: false, + Success: false, + ErrorMessage: ex.Message, + Timestamp: DateTimeOffset.UtcNow); + } + } +} diff --git a/src/ScadaLink.SiteEventLogging/IEventLogQueryService.cs b/src/ScadaLink.SiteEventLogging/IEventLogQueryService.cs new file mode 100644 index 0000000..24142b7 --- /dev/null +++ b/src/ScadaLink.SiteEventLogging/IEventLogQueryService.cs @@ -0,0 +1,12 @@ +using ScadaLink.Commons.Messages.RemoteQuery; + +namespace ScadaLink.SiteEventLogging; + +/// +/// Interface for querying site event logs. +/// Used by Communication Layer to process remote queries from central. +/// +public interface IEventLogQueryService +{ + EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request); +} diff --git a/src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs b/src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs new file mode 100644 index 0000000..53f41aa --- /dev/null +++ b/src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs @@ -0,0 +1,24 @@ +namespace ScadaLink.SiteEventLogging; + +/// +/// Interface for recording operational events to the local SQLite event log. +/// +public interface ISiteEventLogger +{ + /// + /// Record an event asynchronously. + /// + /// Category: script, alarm, deployment, connection, store_and_forward, instance_lifecycle + /// Info, Warning, or Error + /// Optional instance ID associated with the event + /// Source identifier, e.g., "ScriptActor:MonitorSpeed" + /// Human-readable event description + /// Optional JSON details (stack traces, compilation errors, etc.) + Task LogEventAsync( + string eventType, + string severity, + string? instanceId, + string source, + string message, + string? details = null); +} diff --git a/src/ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj b/src/ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj index 049c7d9..080a38e 100644 --- a/src/ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj +++ b/src/ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj @@ -8,7 +8,10 @@ + + + @@ -16,4 +19,8 @@ + + + + diff --git a/src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs b/src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs index 072743b..617fa76 100644 --- a/src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs @@ -4,15 +4,20 @@ namespace ScadaLink.SiteEventLogging; public static class ServiceCollectionExtensions { + /// + /// Register site event logging services (recording, purge, query). + /// public static IServiceCollection AddSiteEventLogging(this IServiceCollection services) { - // Phase 0: skeleton only + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); return services; } public static IServiceCollection AddSiteEventLoggingActors(this IServiceCollection services) { - // Phase 0: placeholder for Akka actor registration + // Placeholder for Akka actor registration (Phase 4+) return services; } } diff --git a/src/ScadaLink.SiteEventLogging/SiteEventLogOptions.cs b/src/ScadaLink.SiteEventLogging/SiteEventLogOptions.cs index 5d12b36..ec7cc5a 100644 --- a/src/ScadaLink.SiteEventLogging/SiteEventLogOptions.cs +++ b/src/ScadaLink.SiteEventLogging/SiteEventLogOptions.cs @@ -4,5 +4,7 @@ public class SiteEventLogOptions { public int RetentionDays { get; set; } = 30; public int MaxStorageMb { get; set; } = 1024; - public string PurgeScheduleCron { get; set; } = "0 2 * * *"; + public string DatabasePath { get; set; } = "site_events.db"; + public int QueryPageSize { get; set; } = 500; + public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromHours(24); } diff --git a/src/ScadaLink.SiteEventLogging/SiteEventLogger.cs b/src/ScadaLink.SiteEventLogging/SiteEventLogger.cs new file mode 100644 index 0000000..34f0ea1 --- /dev/null +++ b/src/ScadaLink.SiteEventLogging/SiteEventLogger.cs @@ -0,0 +1,107 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ScadaLink.SiteEventLogging; + +/// +/// Records operational events to a local SQLite database. +/// Only the active node generates events. Not replicated to standby. +/// On failover, the new active node starts a fresh log. +/// +public class SiteEventLogger : ISiteEventLogger, IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ILogger _logger; + private readonly object _writeLock = new(); + private bool _disposed; + + public SiteEventLogger( + IOptions options, + ILogger logger, + string? connectionStringOverride = null) + { + _logger = logger; + + var connectionString = connectionStringOverride + ?? $"Data Source={options.Value.DatabasePath};Cache=Shared"; + _connection = new SqliteConnection(connectionString); + _connection.Open(); + + InitializeSchema(); + } + + internal SqliteConnection Connection => _connection; + + private void InitializeSchema() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS site_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL, + instance_id TEXT, + source TEXT NOT NULL, + message TEXT NOT NULL, + details TEXT + ); + CREATE INDEX IF NOT EXISTS idx_events_timestamp ON site_events(timestamp); + CREATE INDEX IF NOT EXISTS idx_events_type ON site_events(event_type); + CREATE INDEX IF NOT EXISTS idx_events_instance ON site_events(instance_id); + """; + cmd.ExecuteNonQuery(); + } + + public Task LogEventAsync( + string eventType, + string severity, + string? instanceId, + string source, + string message, + string? details = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventType); + ArgumentException.ThrowIfNullOrWhiteSpace(severity); + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + var timestamp = DateTimeOffset.UtcNow.ToString("o"); + + lock (_writeLock) + { + if (_disposed) return Task.CompletedTask; + + try + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO site_events (timestamp, event_type, severity, instance_id, source, message, details) + VALUES ($timestamp, $event_type, $severity, $instance_id, $source, $message, $details) + """; + cmd.Parameters.AddWithValue("$timestamp", timestamp); + cmd.Parameters.AddWithValue("$event_type", eventType); + cmd.Parameters.AddWithValue("$severity", severity); + cmd.Parameters.AddWithValue("$instance_id", (object?)instanceId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$source", source); + cmd.Parameters.AddWithValue("$message", message); + cmd.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value); + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record event: {EventType} from {Source}", eventType, source); + } + } + + return Task.CompletedTask; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _connection.Dispose(); + } +} diff --git a/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs b/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs new file mode 100644 index 0000000..8901956 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs @@ -0,0 +1,305 @@ +using Akka.Actor; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.SiteRuntime.Scripts; +using System.Text.Json; + +namespace ScadaLink.SiteRuntime.Actors; + +/// +/// WP-16: Alarm Actor — coordinator actor, child of Instance Actor, peer to Script Actors. +/// Subscribes to attribute change notifications from Instance Actor. +/// +/// Evaluates alarm conditions: +/// - ValueMatch: attribute equals a specific value +/// - RangeViolation: attribute outside min/max range +/// - RateOfChange: attribute rate exceeds threshold (configurable window, default per-second) +/// +/// State (active/normal) is in memory only, NOT persisted. +/// On restart: starts normal, re-evaluates from incoming values. +/// +/// WP-21: AlarmExecutionActor CAN call Instance.CallScript() (ask to sibling Script Actor). +/// Instance scripts CANNOT call alarm on-trigger scripts (no Instance.CallAlarmScript API). +/// +/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception. +/// +public class AlarmActor : ReceiveActor +{ + private readonly string _alarmName; + private readonly string _instanceName; + private readonly IActorRef _instanceActor; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteRuntimeOptions _options; + private readonly ILogger _logger; + + private AlarmState _currentState = AlarmState.Normal; + private readonly AlarmTriggerType _triggerType; + private readonly AlarmEvalConfig _evalConfig; + private readonly int _priority; + private readonly string? _onTriggerScriptName; + private readonly Script? _onTriggerCompiledScript; + + // Rate of change tracking + private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new(); + private readonly TimeSpan _rateOfChangeWindowDuration; + + private int _executionCounter; + + public AlarmActor( + string alarmName, + string instanceName, + IActorRef instanceActor, + ResolvedAlarm alarmConfig, + Script? onTriggerCompiledScript, + SharedScriptLibrary sharedScriptLibrary, + SiteRuntimeOptions options, + ILogger logger) + { + _alarmName = alarmName; + _instanceName = instanceName; + _instanceActor = instanceActor; + _sharedScriptLibrary = sharedScriptLibrary; + _options = options; + _logger = logger; + _priority = alarmConfig.PriorityLevel; + _onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName; + _onTriggerCompiledScript = onTriggerCompiledScript; + + // Parse trigger type + _triggerType = Enum.TryParse(alarmConfig.TriggerType, true, out var tt) + ? tt : AlarmTriggerType.ValueMatch; + + _evalConfig = ParseEvalConfig(alarmConfig.TriggerConfiguration); + _rateOfChangeWindowDuration = _evalConfig is RateOfChangeEvalConfig roc + ? roc.WindowDuration + : TimeSpan.FromSeconds(1); + + // Handle attribute value changes + Receive(HandleAttributeValueChanged); + + // Handle alarm execution completion + Receive(_ => + _logger.LogDebug("Alarm {Alarm} execution completed on {Instance}", _alarmName, _instanceName)); + } + + protected override void PreStart() + { + base.PreStart(); + _logger.LogInformation( + "AlarmActor {Alarm} started on instance {Instance}, trigger={TriggerType}", + _alarmName, _instanceName, _triggerType); + } + + /// + /// Supervision: Resume on exception; AlarmExecutionActor stopped on exception. + /// + protected override SupervisorStrategy SupervisorStrategy() + { + return new OneForOneStrategy( + maxNrOfRetries: -1, + withinTimeRange: TimeSpan.FromMinutes(1), + decider: Decider.From(ex => + { + _logger.LogWarning(ex, + "AlarmExecutionActor for {Alarm} on {Instance} failed, stopping", + _alarmName, _instanceName); + return Directive.Stop; + })); + } + + /// + /// Evaluates alarm condition on attribute change. Alarm evaluation errors are logged, + /// actor continues (does not crash). + /// + private void HandleAttributeValueChanged(AttributeValueChanged changed) + { + // Only evaluate if this change is for an attribute we're monitoring + if (!IsMonitoredAttribute(changed.AttributeName)) + return; + + try + { + var isTriggered = _triggerType switch + { + AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value), + AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value), + AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp), + _ => false + }; + + if (isTriggered && _currentState == AlarmState.Normal) + { + // Transition: Normal → Active + _currentState = AlarmState.Active; + _logger.LogInformation( + "Alarm {Alarm} ACTIVATED on instance {Instance}", + _alarmName, _instanceName); + + // Notify Instance Actor of alarm state change + var alarmChanged = new AlarmStateChanged( + _instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow); + _instanceActor.Tell(alarmChanged); + + // Spawn AlarmExecutionActor if on-trigger script defined + if (_onTriggerCompiledScript != null) + { + SpawnAlarmExecution(); + } + } + else if (!isTriggered && _currentState == AlarmState.Active) + { + // Transition: Active → Normal (no script on clear) + _currentState = AlarmState.Normal; + _logger.LogInformation( + "Alarm {Alarm} CLEARED on instance {Instance}", + _alarmName, _instanceName); + + var alarmChanged = new AlarmStateChanged( + _instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow); + _instanceActor.Tell(alarmChanged); + } + } + catch (Exception ex) + { + // Alarm evaluation errors logged, actor continues + _logger.LogError(ex, + "Alarm {Alarm} evaluation error on {Instance}", + _alarmName, _instanceName); + } + } + + private bool IsMonitoredAttribute(string attributeName) + { + return _evalConfig.MonitoredAttributeName == attributeName; + } + + private bool EvaluateValueMatch(object? value) + { + if (_evalConfig is not ValueMatchEvalConfig config) return false; + if (value == null) return config.MatchValue == null; + return string.Equals(value.ToString(), config.MatchValue, StringComparison.Ordinal); + } + + private bool EvaluateRangeViolation(object? value) + { + if (_evalConfig is not RangeViolationEvalConfig config) return false; + if (value == null) return false; + + try + { + var numericValue = Convert.ToDouble(value); + return numericValue < config.Min || numericValue > config.Max; + } + catch + { + return false; + } + } + + private bool EvaluateRateOfChange(object? value, DateTimeOffset timestamp) + { + if (_evalConfig is not RateOfChangeEvalConfig config) return false; + if (value == null) return false; + + try + { + var numericValue = Convert.ToDouble(value); + + // Add to window + _rateOfChangeWindow.Enqueue((timestamp, numericValue)); + + // Remove old entries outside the window + var cutoff = timestamp - _rateOfChangeWindowDuration; + while (_rateOfChangeWindow.Count > 0 && _rateOfChangeWindow.Peek().Timestamp < cutoff) + { + _rateOfChangeWindow.Dequeue(); + } + + if (_rateOfChangeWindow.Count < 2) return false; + + var oldest = _rateOfChangeWindow.Peek(); + var timeDelta = (timestamp - oldest.Timestamp).TotalSeconds; + if (timeDelta <= 0) return false; + + var rate = Math.Abs(numericValue - oldest.Value) / timeDelta; + return rate > config.ThresholdPerSecond; + } + catch + { + return false; + } + } + + /// + /// Spawns an AlarmExecutionActor to run the on-trigger script. + /// + private void SpawnAlarmExecution() + { + if (_onTriggerCompiledScript == null) return; + + var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}"; + + // NOTE: In production, configure a dedicated blocking I/O dispatcher via HOCON. + var props = Props.Create(() => new AlarmExecutionActor( + _alarmName, + _instanceName, + _onTriggerCompiledScript, + _instanceActor, + _sharedScriptLibrary, + _options, + _logger)); + + Context.ActorOf(props, executionId); + } + + private AlarmEvalConfig ParseEvalConfig(string? triggerConfigJson) + { + if (string.IsNullOrEmpty(triggerConfigJson)) + return new ValueMatchEvalConfig("", null); + + try + { + var doc = JsonDocument.Parse(triggerConfigJson); + var attr = doc.RootElement.TryGetProperty("attributeName", out var attrEl) + ? attrEl.GetString() ?? "" : ""; + + return _triggerType switch + { + AlarmTriggerType.ValueMatch => new ValueMatchEvalConfig( + attr, + doc.RootElement.TryGetProperty("matchValue", out var mv) ? mv.GetString() : null), + + AlarmTriggerType.RangeViolation => new RangeViolationEvalConfig( + attr, + doc.RootElement.TryGetProperty("min", out var minEl) ? minEl.GetDouble() : double.MinValue, + doc.RootElement.TryGetProperty("max", out var maxEl) ? maxEl.GetDouble() : double.MaxValue), + + AlarmTriggerType.RateOfChange => new RateOfChangeEvalConfig( + attr, + doc.RootElement.TryGetProperty("thresholdPerSecond", out var tps) ? tps.GetDouble() : 10.0, + doc.RootElement.TryGetProperty("windowSeconds", out var ws) + ? TimeSpan.FromSeconds(ws.GetDouble()) + : TimeSpan.FromSeconds(1)), + + _ => new ValueMatchEvalConfig(attr, null) + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse alarm trigger config for {Alarm}", _alarmName); + return new ValueMatchEvalConfig("", null); + } + } + + // ── Internal messages ── + internal record AlarmExecutionCompleted(string AlarmName, bool Success); +} + +// ── Alarm evaluation config types ── +internal abstract record AlarmEvalConfig(string MonitoredAttributeName); +internal record ValueMatchEvalConfig(string MonitoredAttributeName, string? MatchValue) : AlarmEvalConfig(MonitoredAttributeName); +internal record RangeViolationEvalConfig(string MonitoredAttributeName, double Min, double Max) : AlarmEvalConfig(MonitoredAttributeName); +internal record RateOfChangeEvalConfig(string MonitoredAttributeName, double ThresholdPerSecond, TimeSpan WindowDuration) : AlarmEvalConfig(MonitoredAttributeName); diff --git a/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs new file mode 100644 index 0000000..1598cbd --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs @@ -0,0 +1,96 @@ +using Akka.Actor; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Actors; + +/// +/// WP-16: Alarm Execution Actor -- short-lived child of Alarm Actor. +/// Same pattern as ScriptExecutionActor. +/// WP-21: CAN call Instance.CallScript() (ask to sibling Script Actor). +/// Instance scripts CANNOT call alarm on-trigger scripts (no API for it). +/// Supervision: Stop on unhandled exception. +/// +public class AlarmExecutionActor : ReceiveActor +{ + public AlarmExecutionActor( + string alarmName, + string instanceName, + Script compiledScript, + IActorRef instanceActor, + SharedScriptLibrary sharedScriptLibrary, + SiteRuntimeOptions options, + ILogger logger) + { + var self = Self; + var parent = Context.Parent; + + ExecuteAlarmScript( + alarmName, instanceName, compiledScript, instanceActor, + sharedScriptLibrary, options, self, parent, logger); + } + + private static void ExecuteAlarmScript( + string alarmName, + string instanceName, + Script compiledScript, + IActorRef instanceActor, + SharedScriptLibrary sharedScriptLibrary, + SiteRuntimeOptions options, + IActorRef self, + IActorRef parent, + ILogger logger) + { + var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); + + _ = Task.Run(async () => + { + using var cts = new CancellationTokenSource(timeout); + try + { + // WP-21: AlarmExecutionActor can call Instance.CallScript() + // via the ScriptRuntimeContext injected into globals + var context = new ScriptRuntimeContext( + instanceActor, + self, + sharedScriptLibrary, + currentCallDepth: 0, + options.MaxScriptCallDepth, + timeout, + instanceName, + logger); + + var globals = new ScriptGlobals + { + Instance = context, + Parameters = new Dictionary(), + CancellationToken = cts.Token + }; + + await compiledScript.RunAsync(globals, cts.Token); + + parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true)); + } + catch (OperationCanceledException) + { + logger.LogWarning( + "Alarm on-trigger script for {Alarm} on {Instance} timed out", + alarmName, instanceName); + parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false)); + } + catch (Exception ex) + { + // WP-32: Failures logged, alarm continues + logger.LogError(ex, + "Alarm on-trigger script for {Alarm} on {Instance} failed", + alarmName, instanceName); + parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false)); + } + finally + { + self.Tell(PoisonPill.Instance); + } + }); + } +} diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index 506b10c..b201c84 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -1,9 +1,12 @@ using Akka.Actor; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Types.Enums; using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; +using ScadaLink.SiteRuntime.Streaming; namespace ScadaLink.SiteRuntime.Actors; @@ -20,6 +23,9 @@ namespace ScadaLink.SiteRuntime.Actors; public class DeploymentManagerActor : ReceiveActor, IWithTimers { private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteStreamManager? _streamManager; private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly Dictionary _instanceActors = new(); @@ -28,10 +34,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers public DeploymentManagerActor( SiteStorageService storage, + ScriptCompilationService compilationService, + SharedScriptLibrary sharedScriptLibrary, + SiteStreamManager? streamManager, SiteRuntimeOptions options, ILogger logger) { _storage = storage; + _compilationService = compilationService; + _sharedScriptLibrary = sharedScriptLibrary; + _streamManager = streamManager; _options = options; _logger = logger; @@ -41,6 +53,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(HandleEnable); Receive(HandleDelete); + // WP-33: Handle system-wide artifact deployment + Receive(HandleDeployArtifacts); + // Internal startup messages Receive(HandleStartupConfigsLoaded); Receive(HandleStartNextBatch); @@ -317,6 +332,74 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers _logger.LogInformation("Instance {Instance} deleted", instanceName); } + /// + /// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.). + /// Persists artifacts to SiteStorageService and recompiles shared scripts. + /// + private void HandleDeployArtifacts(DeployArtifactsCommand command) + { + _logger.LogInformation( + "Deploying system artifacts, deploymentId={DeploymentId}", command.DeploymentId); + + var sender = Sender; + + Task.Run(async () => + { + try + { + // WP-33: Store shared scripts and recompile + if (command.SharedScripts != null) + { + foreach (var script in command.SharedScripts) + { + await _storage.StoreSharedScriptAsync(script.Name, script.Code, + script.ParameterDefinitions, script.ReturnDefinition); + + // WP-33: Shared scripts recompiled on update + _sharedScriptLibrary.CompileAndRegister(script.Name, script.Code); + } + } + + // WP-33: Store external system definitions + if (command.ExternalSystems != null) + { + foreach (var es in command.ExternalSystems) + { + await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl, + es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson); + } + } + + // WP-33: Store database connection definitions + if (command.DatabaseConnections != null) + { + foreach (var db in command.DatabaseConnections) + { + await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString, + db.MaxRetries, db.RetryDelay); + } + } + + // WP-33: Store notification lists + if (command.NotificationLists != null) + { + foreach (var nl in command.NotificationLists) + { + await _storage.StoreNotificationListAsync(nl.Name, nl.RecipientEmails); + } + } + + return new ArtifactDeploymentResponse( + command.DeploymentId, "", true, null, DateTimeOffset.UtcNow); + } + catch (Exception ex) + { + return new ArtifactDeploymentResponse( + command.DeploymentId, "", false, ex.Message, DateTimeOffset.UtcNow); + } + }).PipeTo(sender); + } + /// /// Creates a child Instance Actor with the given name and configuration JSON. /// @@ -333,6 +416,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers instanceName, configJson, _storage, + _compilationService, + _sharedScriptLibrary, + _streamManager, + _options, loggerFactory.CreateLogger())); var actorRef = Context.ActorOf(props, instanceName); diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index 0000751..10bcb98 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -1,9 +1,15 @@ using Akka.Actor; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Lifecycle; +using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; +using ScadaLink.SiteRuntime.Streaming; using System.Text.Json; namespace ScadaLink.SiteRuntime.Actors; @@ -13,24 +19,48 @@ namespace ScadaLink.SiteRuntime.Actors; /// (loaded from FlattenedConfiguration + static overrides from SQLite). /// /// The Instance Actor is the single source of truth for runtime instance state. -/// All state mutations are serialized through the actor mailbox. +/// WP-24: All state mutations are serialized through the actor mailbox. +/// Multiple Script Execution Actors run concurrently; state mutations through this actor. +/// +/// WP-15/16: Creates child Script Actors and Alarm Actors on startup. +/// WP-22: Tell for tag value updates, attribute notifications, stream publishing. +/// Ask for CallScript, debug snapshot. +/// WP-25: Debug view backend — snapshot + stream subscription. /// public class InstanceActor : ReceiveActor { private readonly string _instanceUniqueName; private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteStreamManager? _streamManager; + private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly Dictionary _attributes = new(); + private readonly Dictionary _alarmStates = new(); + private readonly Dictionary _scriptActors = new(); + private readonly Dictionary _alarmActors = new(); private FlattenedConfiguration? _configuration; + // WP-25: Debug view subscribers + private readonly Dictionary _debugSubscribers = new(); + public InstanceActor( string instanceUniqueName, string configJson, SiteStorageService storage, + ScriptCompilationService compilationService, + SharedScriptLibrary sharedScriptLibrary, + SiteStreamManager? streamManager, + SiteRuntimeOptions options, ILogger logger) { _instanceUniqueName = instanceUniqueName; _storage = storage; + _compilationService = compilationService; + _sharedScriptLibrary = sharedScriptLibrary; + _streamManager = streamManager; + _options = options; _logger = logger; // Deserialize the flattened configuration @@ -45,7 +75,7 @@ public class InstanceActor : ReceiveActor } } - // Handle attribute queries (Tell pattern — sender gets response) + // Handle attribute queries (Tell pattern -- sender gets response) Receive(HandleGetAttribute); // Handle static attribute writes @@ -55,7 +85,6 @@ public class InstanceActor : ReceiveActor Receive(_ => { _logger.LogInformation("Instance {Instance} received disable command", _instanceUniqueName); - // Disable handled by parent DeploymentManagerActor Sender.Tell(new InstanceLifecycleResponse( _.CommandId, _instanceUniqueName, true, null, DateTimeOffset.UtcNow)); }); @@ -67,6 +96,19 @@ public class InstanceActor : ReceiveActor _.CommandId, _instanceUniqueName, true, null, DateTimeOffset.UtcNow)); }); + // WP-15: Handle script call requests — route to appropriate Script Actor (Ask pattern) + Receive(HandleScriptCallRequest); + + // WP-22/23: Handle attribute value changes from DCL (Tell pattern) + Receive(HandleAttributeValueChanged); + + // WP-16: Handle alarm state changes from Alarm Actors (Tell pattern) + Receive(HandleAlarmStateChanged); + + // WP-25: Debug view subscribe/unsubscribe (Ask pattern for snapshot) + Receive(HandleSubscribeDebugView); + Receive(HandleUnsubscribeDebugView); + // Handle internal messages Receive(HandleOverridesLoaded); } @@ -84,6 +126,26 @@ public class InstanceActor : ReceiveActor return new LoadOverridesResult(t.Result, null); return new LoadOverridesResult(new Dictionary(), t.Exception?.GetBaseException().Message); }).PipeTo(self); + + // Create child Script Actors and Alarm Actors from configuration + CreateChildActors(); + } + + /// + /// Supervision: Resume for child coordinator actors (Script/Alarm Actors preserve state). + /// + protected override SupervisorStrategy SupervisorStrategy() + { + return new OneForOneStrategy( + maxNrOfRetries: -1, + withinTimeRange: TimeSpan.FromMinutes(1), + decider: Decider.From(ex => + { + _logger.LogWarning(ex, + "Child actor on instance {Instance} threw exception, resuming", + _instanceUniqueName); + return Directive.Resume; + })); } /// @@ -103,12 +165,24 @@ public class InstanceActor : ReceiveActor /// /// Updates a static attribute in memory and persists the override to SQLite. + /// WP-24: State mutation serialized through this actor's mailbox. /// private void HandleSetStaticAttribute(SetStaticAttributeCommand command) { _attributes[command.AttributeName] = command.Value; - // Persist asynchronously — fire and forget since the actor is the source of truth + // Publish attribute change to stream (WP-23) and notify children + var changed = new AttributeValueChanged( + _instanceUniqueName, + command.AttributeName, + command.AttributeName, + command.Value, + "Good", + DateTimeOffset.UtcNow); + + PublishAndNotifyChildren(changed); + + // Persist asynchronously -- fire and forget since the actor is the source of truth var self = Self; var sender = Sender; _storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value) @@ -131,6 +205,138 @@ public class InstanceActor : ReceiveActor }).PipeTo(sender); } + /// + /// WP-15: Routes script call requests to the appropriate Script Actor. + /// Uses Ask pattern (WP-22). + /// + private void HandleScriptCallRequest(ScriptCallRequest request) + { + if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor)) + { + // Forward the request to the Script Actor, preserving the original sender + scriptActor.Forward(request); + } + else + { + Sender.Tell(new ScriptCallResult( + request.CorrelationId, + false, + null, + $"Script '{request.ScriptName}' not found on instance '{_instanceUniqueName}'.")); + } + } + + /// + /// WP-22/23: Handles attribute value changes from DCL or static writes. + /// Updates in-memory state, publishes to stream, and notifies children. + /// + private void HandleAttributeValueChanged(AttributeValueChanged changed) + { + // WP-24: State mutation serialized through this actor + _attributes[changed.AttributeName] = changed.Value; + + PublishAndNotifyChildren(changed); + } + + /// + /// WP-16: Handles alarm state changes from Alarm Actors. + /// Updates in-memory alarm state and publishes to stream. + /// + private void HandleAlarmStateChanged(AlarmStateChanged changed) + { + _alarmStates[changed.AlarmName] = changed.State; + + // WP-23: Publish to site-wide stream + _streamManager?.PublishAlarmStateChanged(changed); + + // Forward to debug subscribers + foreach (var sub in _debugSubscribers.Values) + { + sub.Tell(changed); + } + } + + /// + /// WP-25: Debug view subscribe — returns snapshot and begins streaming. + /// + private void HandleSubscribeDebugView(SubscribeDebugViewRequest request) + { + var subscriptionId = request.CorrelationId; + _debugSubscribers[subscriptionId] = Sender; + + // Build snapshot from current state + var attributeValues = _attributes.Select(kvp => new AttributeValueChanged( + _instanceUniqueName, + kvp.Key, + kvp.Key, + kvp.Value, + "Good", + DateTimeOffset.UtcNow)).ToList(); + + var alarmStates = _alarmStates.Select(kvp => new AlarmStateChanged( + _instanceUniqueName, + kvp.Key, + kvp.Value, + 0, // Priority not tracked in _alarmStates; would need separate tracking + DateTimeOffset.UtcNow)).ToList(); + + var snapshot = new DebugViewSnapshot( + _instanceUniqueName, + attributeValues, + alarmStates, + DateTimeOffset.UtcNow); + + Sender.Tell(snapshot); + + // Also register with stream manager for filtered events + _streamManager?.Subscribe(_instanceUniqueName, Sender); + + _logger.LogDebug( + "Debug view subscriber added for {Instance}, subscriptionId={Id}", + _instanceUniqueName, subscriptionId); + } + + /// + /// WP-25: Debug view unsubscribe — removes subscription. + /// + private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request) + { + _debugSubscribers.Remove(request.CorrelationId); + _streamManager?.RemoveSubscriber(Sender); + + _logger.LogDebug( + "Debug view subscriber removed for {Instance}, correlationId={Id}", + _instanceUniqueName, request.CorrelationId); + } + + /// + /// Publishes attribute change to stream and notifies child Script/Alarm actors. + /// WP-22: Tell for attribute notifications (fire-and-forget, never blocks). + /// + private void PublishAndNotifyChildren(AttributeValueChanged changed) + { + // WP-23: Publish to site-wide stream + _streamManager?.PublishAttributeValueChanged(changed); + + // Notify Script Actors (for value-change and conditional triggers) + foreach (var scriptActor in _scriptActors.Values) + { + scriptActor.Tell(changed); + } + + // Notify Alarm Actors (for alarm evaluation) + foreach (var alarmActor in _alarmActors.Values) + { + alarmActor.Tell(changed); + } + + // Forward to debug subscribers + foreach (var sub in _debugSubscribers.Values) + { + sub.Tell(changed); + } + } + /// /// Applies static overrides loaded from SQLite on top of default values. /// @@ -154,11 +360,105 @@ public class InstanceActor : ReceiveActor result.Overrides.Count, _instanceUniqueName); } + /// + /// Creates child Script Actors and Alarm Actors from the flattened configuration. + /// WP-15: Script Actors spawned per script definition. + /// WP-16: Alarm Actors spawned per alarm definition, as peers to Script Actors. + /// WP-32: Compilation errors reject entire instance deployment (logged but actor still starts). + /// + private void CreateChildActors() + { + if (_configuration == null) return; + + // Create Script Actors + foreach (var script in _configuration.Scripts) + { + var compilationResult = _compilationService.Compile(script.CanonicalName, script.Code); + if (!compilationResult.IsSuccess) + { + _logger.LogError( + "Script '{Script}' on instance '{Instance}' failed to compile: {Errors}", + script.CanonicalName, _instanceUniqueName, + string.Join("; ", compilationResult.Errors)); + continue; + } + + var props = Props.Create(() => new ScriptActor( + script.CanonicalName, + _instanceUniqueName, + Self, + compilationResult.CompiledScript, + script, + _sharedScriptLibrary, + _options, + _logger)); + + var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}"); + _scriptActors[script.CanonicalName] = actorRef; + } + + // Create Alarm Actors + foreach (var alarm in _configuration.Alarms) + { + Microsoft.CodeAnalysis.Scripting.Script? onTriggerScript = null; + + // Compile on-trigger script if defined + if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName)) + { + var triggerScriptDef = _configuration.Scripts + .FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName); + + if (triggerScriptDef != null) + { + var result = _compilationService.Compile( + $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code); + if (result.IsSuccess) + { + onTriggerScript = result.CompiledScript; + } + else + { + _logger.LogWarning( + "Alarm trigger script for {Alarm} on {Instance} failed to compile", + alarm.CanonicalName, _instanceUniqueName); + } + } + } + + var props = Props.Create(() => new AlarmActor( + alarm.CanonicalName, + _instanceUniqueName, + Self, + alarm, + onTriggerScript, + _sharedScriptLibrary, + _options, + _logger)); + + var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}"); + _alarmActors[alarm.CanonicalName] = actorRef; + } + + _logger.LogInformation( + "Instance {Instance}: created {Scripts} script actors and {Alarms} alarm actors", + _instanceUniqueName, _scriptActors.Count, _alarmActors.Count); + } + /// /// Read-only access to current attribute count (for testing/diagnostics). /// public int AttributeCount => _attributes.Count; + /// + /// Read-only access to script actor count (for testing/diagnostics). + /// + public int ScriptActorCount => _scriptActors.Count; + + /// + /// Read-only access to alarm actor count (for testing/diagnostics). + /// + public int AlarmActorCount => _alarmActors.Count; + /// /// Internal message for async override loading result. /// diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs new file mode 100644 index 0000000..ecec82f --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs @@ -0,0 +1,313 @@ +using Akka.Actor; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.SiteRuntime.Scripts; +using System.Text.Json; + +namespace ScadaLink.SiteRuntime.Actors; + +/// +/// WP-15: Script Actor — coordinator actor, child of Instance Actor. +/// Holds compiled script delegate, manages trigger configuration, and spawns +/// ScriptExecutionActor children per invocation. Does not block on child completion. +/// +/// Trigger types: +/// - Interval: uses Akka timers to fire periodically +/// - ValueChange: receives attribute change notifications from Instance Actor +/// - Conditional: evaluates a condition on attribute change +/// +/// Supervision strategy: Resume on exception (coordinator preserves state). +/// +public class ScriptActor : ReceiveActor, IWithTimers +{ + private readonly string _scriptName; + private readonly string _instanceName; + private readonly IActorRef _instanceActor; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteRuntimeOptions _options; + private readonly ILogger _logger; + + private Script? _compiledScript; + private ScriptTriggerConfig? _triggerConfig; + private TimeSpan? _minTimeBetweenRuns; + private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue; + private int _executionCounter; + + public ITimerScheduler Timers { get; set; } = null!; + + public ScriptActor( + string scriptName, + string instanceName, + IActorRef instanceActor, + Script? compiledScript, + ResolvedScript scriptConfig, + SharedScriptLibrary sharedScriptLibrary, + SiteRuntimeOptions options, + ILogger logger) + { + _scriptName = scriptName; + _instanceName = instanceName; + _instanceActor = instanceActor; + _compiledScript = compiledScript; + _sharedScriptLibrary = sharedScriptLibrary; + _options = options; + _logger = logger; + _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; + + // Parse trigger configuration + _triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration); + + // Handle script call requests (Ask pattern from Instance Actor or ScriptRuntimeContext) + Receive(HandleScriptCallRequest); + + // Handle attribute value changes for value-change and conditional triggers + Receive(HandleAttributeValueChanged); + + // Handle interval tick + Receive(_ => TrySpawnExecution(null)); + + // Handle execution completion (for logging/metrics) + Receive(HandleExecutionCompleted); + } + + protected override void PreStart() + { + base.PreStart(); + + // Set up interval trigger if configured + if (_triggerConfig is IntervalTriggerConfig interval) + { + Timers.StartPeriodicTimer( + "interval-trigger", + IntervalTick.Instance, + interval.Interval, + interval.Interval); + + _logger.LogDebug( + "ScriptActor {Script} on {Instance}: interval trigger set to {Interval}", + _scriptName, _instanceName, interval.Interval); + } + + _logger.LogInformation( + "ScriptActor {Script} started on instance {Instance}", + _scriptName, _instanceName); + } + + /// + /// Supervision: Resume on exception — coordinator preserves state. + /// ScriptExecutionActors are stopped on unhandled exceptions. + /// + protected override SupervisorStrategy SupervisorStrategy() + { + return new OneForOneStrategy( + maxNrOfRetries: -1, + withinTimeRange: TimeSpan.FromMinutes(1), + decider: Decider.From(ex => + { + _logger.LogWarning(ex, + "ScriptExecutionActor for {Script} on {Instance} failed, stopping", + _scriptName, _instanceName); + return Directive.Stop; + })); + } + + /// + /// Handles CallScript ask from ScriptRuntimeContext or Instance Actor. + /// Spawns a ScriptExecutionActor and forwards the sender for reply. + /// + private void HandleScriptCallRequest(ScriptCallRequest request) + { + if (_compiledScript == null) + { + Sender.Tell(new ScriptCallResult( + request.CorrelationId, + false, + null, + $"Script '{_scriptName}' is not compiled.")); + return; + } + + SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId); + } + + /// + /// Handles attribute value changes — triggers script if configured for value-change or conditional. + /// + private void HandleAttributeValueChanged(AttributeValueChanged changed) + { + if (_triggerConfig is ValueChangeTriggerConfig valueTrigger) + { + if (valueTrigger.AttributeName == changed.AttributeName) + { + TrySpawnExecution(null); + } + } + else if (_triggerConfig is ConditionalTriggerConfig conditional) + { + if (conditional.AttributeName == changed.AttributeName) + { + // Evaluate condition + if (EvaluateCondition(conditional, changed.Value)) + { + TrySpawnExecution(null); + } + } + } + } + + /// + /// Attempts to spawn a script execution, respecting MinTimeBetweenRuns. + /// + private void TrySpawnExecution(IReadOnlyDictionary? parameters) + { + if (_compiledScript == null) return; + + if (_minTimeBetweenRuns.HasValue) + { + var elapsed = DateTimeOffset.UtcNow - _lastExecutionTime; + if (elapsed < _minTimeBetweenRuns.Value) + { + _logger.LogDebug( + "Script {Script} on {Instance}: skipping execution, min time between runs not elapsed ({Elapsed} < {Min})", + _scriptName, _instanceName, elapsed, _minTimeBetweenRuns.Value); + return; + } + } + + _lastExecutionTime = DateTimeOffset.UtcNow; + SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString()); + } + + /// + /// Spawns a new ScriptExecutionActor child for this invocation. + /// Multiple concurrent executions are allowed. + /// + private void SpawnExecution( + IReadOnlyDictionary? parameters, + int callDepth, + IActorRef replyTo, + string correlationId) + { + var executionId = $"{_scriptName}-exec-{_executionCounter++}"; + + // NOTE: In production, configure a dedicated blocking I/O dispatcher via HOCON: + // akka.actor.script-execution-dispatcher { type = PinnedDispatcher } + // and chain .WithDispatcher("akka.actor.script-execution-dispatcher") below. + var props = Props.Create(() => new ScriptExecutionActor( + _scriptName, + _instanceName, + _compiledScript!, + parameters, + callDepth, + _instanceActor, + _sharedScriptLibrary, + _options, + replyTo, + correlationId, + _logger)); + + Context.ActorOf(props, executionId); + } + + private void HandleExecutionCompleted(ScriptExecutionCompleted msg) + { + _logger.LogDebug( + "Script {Script} execution completed on {Instance}: success={Success}", + _scriptName, _instanceName, msg.Success); + } + + private static bool EvaluateCondition(ConditionalTriggerConfig config, object? value) + { + if (value == null) return false; + + try + { + var numericValue = Convert.ToDouble(value); + return config.Operator switch + { + ">" => numericValue > config.Threshold, + ">=" => numericValue >= config.Threshold, + "<" => numericValue < config.Threshold, + "<=" => numericValue <= config.Threshold, + "==" => Math.Abs(numericValue - config.Threshold) < 0.0001, + "!=" => Math.Abs(numericValue - config.Threshold) >= 0.0001, + _ => false + }; + } + catch + { + return string.Equals(value.ToString(), config.Threshold.ToString(), StringComparison.Ordinal); + } + } + + private static ScriptTriggerConfig? ParseTriggerConfig(string? triggerType, string? triggerConfigJson) + { + if (string.IsNullOrEmpty(triggerType)) return null; + + return triggerType.ToLowerInvariant() switch + { + "interval" => ParseIntervalTrigger(triggerConfigJson), + "valuechange" => ParseValueChangeTrigger(triggerConfigJson), + "conditional" => ParseConditionalTrigger(triggerConfigJson), + _ => null + }; + } + + private static IntervalTriggerConfig? ParseIntervalTrigger(string? json) + { + if (string.IsNullOrEmpty(json)) return null; + try + { + var doc = JsonDocument.Parse(json); + var ms = doc.RootElement.GetProperty("intervalMs").GetInt64(); + return new IntervalTriggerConfig(TimeSpan.FromMilliseconds(ms)); + } + catch { return null; } + } + + private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json) + { + if (string.IsNullOrEmpty(json)) return null; + try + { + var doc = JsonDocument.Parse(json); + var attr = doc.RootElement.GetProperty("attributeName").GetString()!; + return new ValueChangeTriggerConfig(attr); + } + catch { return null; } + } + + private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json) + { + if (string.IsNullOrEmpty(json)) return null; + try + { + var doc = JsonDocument.Parse(json); + var attr = doc.RootElement.GetProperty("attributeName").GetString()!; + var op = doc.RootElement.GetProperty("operator").GetString()!; + var threshold = doc.RootElement.GetProperty("threshold").GetDouble(); + return new ConditionalTriggerConfig(attr, op, threshold); + } + catch { return null; } + } + + // ── Internal messages ── + + internal sealed class IntervalTick + { + public static readonly IntervalTick Instance = new(); + private IntervalTick() { } + } + + internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error); +} + +// ── Trigger config types ── + +internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig; +internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig; +internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig; +internal abstract record ScriptTriggerConfig; diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs new file mode 100644 index 0000000..f1e845a --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -0,0 +1,126 @@ +using Akka.Actor; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Actors; + +/// +/// WP-15: Script Execution Actor -- short-lived child of Script Actor. +/// Receives compiled code, params, Instance Actor ref, and call depth. +/// Runs on a dedicated blocking I/O dispatcher. +/// Executes the script via Script Runtime API, returns result, then stops. +/// +/// WP-32: Script failures are logged but do not disable the script. +/// Supervision: Stop on unhandled exception (parent ScriptActor decides). +/// +public class ScriptExecutionActor : ReceiveActor +{ + public ScriptExecutionActor( + string scriptName, + string instanceName, + Script compiledScript, + IReadOnlyDictionary? parameters, + int callDepth, + IActorRef instanceActor, + SharedScriptLibrary sharedScriptLibrary, + SiteRuntimeOptions options, + IActorRef replyTo, + string correlationId, + ILogger logger) + { + // Immediately begin execution + var self = Self; + var parent = Context.Parent; + + ExecuteScript( + scriptName, instanceName, compiledScript, parameters, callDepth, + instanceActor, sharedScriptLibrary, options, replyTo, correlationId, + self, parent, logger); + } + + private static void ExecuteScript( + string scriptName, + string instanceName, + Script compiledScript, + IReadOnlyDictionary? parameters, + int callDepth, + IActorRef instanceActor, + SharedScriptLibrary sharedScriptLibrary, + SiteRuntimeOptions options, + IActorRef replyTo, + string correlationId, + IActorRef self, + IActorRef parent, + ILogger logger) + { + var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); + + // CTS must be created inside the async lambda so it outlives this method + _ = Task.Run(async () => + { + using var cts = new CancellationTokenSource(timeout); + try + { + var context = new ScriptRuntimeContext( + instanceActor, + self, + sharedScriptLibrary, + callDepth, + options.MaxScriptCallDepth, + timeout, + instanceName, + logger); + + var globals = new ScriptGlobals + { + Instance = context, + Parameters = parameters ?? new Dictionary(), + CancellationToken = cts.Token + }; + + var state = await compiledScript.RunAsync(globals, cts.Token); + + // Send result to requester if this was an Ask-based call + if (!replyTo.IsNobody()) + { + replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null)); + } + + // Notify parent of completion + parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null)); + } + catch (OperationCanceledException) + { + var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s"; + logger.LogWarning(errorMsg); + + if (!replyTo.IsNobody()) + { + replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); + } + + parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); + } + catch (Exception ex) + { + // WP-32: Failures logged to site event log; script NOT disabled after failure + var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}"; + logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName); + + if (!replyTo.IsNobody()) + { + replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); + } + + parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); + } + finally + { + // Stop self after execution completes + self.Tell(PoisonPill.Instance); + } + }); + } +} diff --git a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs index 746ee5b..5ad0436 100644 --- a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs @@ -46,6 +46,37 @@ public class SiteStorageService updated_at TEXT NOT NULL, PRIMARY KEY (instance_unique_name, attribute_name) ); + + CREATE TABLE IF NOT EXISTS shared_scripts ( + name TEXT PRIMARY KEY, + code TEXT NOT NULL, + parameter_definitions TEXT, + return_definition TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS external_systems ( + name TEXT PRIMARY KEY, + endpoint_url TEXT NOT NULL, + auth_type TEXT NOT NULL, + auth_configuration TEXT, + method_definitions TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS database_connections ( + name TEXT PRIMARY KEY, + connection_string TEXT NOT NULL, + max_retries INTEGER NOT NULL DEFAULT 3, + retry_delay_ms INTEGER NOT NULL DEFAULT 1000, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS notification_lists ( + name TEXT PRIMARY KEY, + recipient_emails TEXT NOT NULL, + updated_at TEXT NOT NULL + ); "; await command.ExecuteNonQueryAsync(); @@ -241,6 +272,150 @@ public class SiteStorageService await command.ExecuteNonQueryAsync(); _logger.LogDebug("Cleared static overrides for {Instance}", instanceName); } + + // ── WP-33: Shared Script CRUD ── + + /// + /// Stores or updates a shared script. Uses UPSERT semantics. + /// + public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO shared_scripts (name, code, parameter_definitions, return_definition, updated_at) + VALUES (@name, @code, @paramDefs, @returnDef, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + code = excluded.code, + parameter_definitions = excluded.parameter_definitions, + return_definition = excluded.return_definition, + updated_at = excluded.updated_at"; + + command.Parameters.AddWithValue("@name", name); + command.Parameters.AddWithValue("@code", code); + command.Parameters.AddWithValue("@paramDefs", (object?)parameterDefs ?? DBNull.Value); + command.Parameters.AddWithValue("@returnDef", (object?)returnDef ?? DBNull.Value); + command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + + await command.ExecuteNonQueryAsync(); + _logger.LogDebug("Stored shared script '{Name}'", name); + } + + /// + /// Returns all stored shared scripts. + /// + public async Task> GetAllSharedScriptsAsync() + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new StoredSharedScript + { + Name = reader.GetString(0), + Code = reader.GetString(1), + ParameterDefinitions = reader.IsDBNull(2) ? null : reader.GetString(2), + ReturnDefinition = reader.IsDBNull(3) ? null : reader.GetString(3) + }); + } + + return results; + } + + // ── WP-33: External System CRUD ── + + /// + /// Stores or updates an external system definition. + /// + public async Task StoreExternalSystemAsync( + string name, string endpointUrl, string authType, string? authConfig, string? methodDefs) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO external_systems (name, endpoint_url, auth_type, auth_configuration, method_definitions, updated_at) + VALUES (@name, @url, @authType, @authConfig, @methodDefs, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + endpoint_url = excluded.endpoint_url, + auth_type = excluded.auth_type, + auth_configuration = excluded.auth_configuration, + method_definitions = excluded.method_definitions, + updated_at = excluded.updated_at"; + + command.Parameters.AddWithValue("@name", name); + command.Parameters.AddWithValue("@url", endpointUrl); + command.Parameters.AddWithValue("@authType", authType); + command.Parameters.AddWithValue("@authConfig", (object?)authConfig ?? DBNull.Value); + command.Parameters.AddWithValue("@methodDefs", (object?)methodDefs ?? DBNull.Value); + command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + + await command.ExecuteNonQueryAsync(); + } + + // ── WP-33: Database Connection CRUD ── + + /// + /// Stores or updates a database connection definition. + /// + public async Task StoreDatabaseConnectionAsync( + string name, string connectionString, int maxRetries, TimeSpan retryDelay) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO database_connections (name, connection_string, max_retries, retry_delay_ms, updated_at) + VALUES (@name, @connStr, @maxRetries, @retryDelayMs, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + connection_string = excluded.connection_string, + max_retries = excluded.max_retries, + retry_delay_ms = excluded.retry_delay_ms, + updated_at = excluded.updated_at"; + + command.Parameters.AddWithValue("@name", name); + command.Parameters.AddWithValue("@connStr", connectionString); + command.Parameters.AddWithValue("@maxRetries", maxRetries); + command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds); + command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + + await command.ExecuteNonQueryAsync(); + } + + // ── WP-33: Notification List CRUD ── + + /// + /// Stores or updates a notification list. + /// + public async Task StoreNotificationListAsync(string name, IReadOnlyList recipientEmails) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO notification_lists (name, recipient_emails, updated_at) + VALUES (@name, @emails, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + recipient_emails = excluded.recipient_emails, + updated_at = excluded.updated_at"; + + command.Parameters.AddWithValue("@name", name); + command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails)); + command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); + + await command.ExecuteNonQueryAsync(); + } } /// @@ -255,3 +430,14 @@ public class DeployedInstance public bool IsEnabled { get; init; } public string DeployedAt { get; init; } = string.Empty; } + +/// +/// Represents a shared script stored locally in SQLite (WP-33). +/// +public class StoredSharedScript +{ + public string Name { get; init; } = string.Empty; + public string Code { get; init; } = string.Empty; + public string? ParameterDefinitions { get; init; } + public string? ReturnDefinition { get; init; } +} diff --git a/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj b/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj index a59e589..f8ce35c 100644 --- a/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj +++ b/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs new file mode 100644 index 0000000..da70924 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -0,0 +1,181 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging; + +namespace ScadaLink.SiteRuntime.Scripts; + +/// +/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access. +/// Forbidden APIs: System.IO, Process, Threading (except async/await), Reflection, +/// System.Net.Sockets, System.Net.Http. +/// +public class ScriptCompilationService +{ + private readonly ILogger _logger; + + /// + /// Namespaces that are forbidden in user scripts for security. + /// + private static readonly string[] ForbiddenNamespaces = + [ + "System.IO", + "System.Diagnostics.Process", + "System.Threading", + "System.Reflection", + "System.Net.Sockets", + "System.Net.Http" + ]; + + /// + /// Specific types/members allowed even within forbidden namespaces. + /// async/await is OK despite System.Threading being blocked. + /// + private static readonly string[] AllowedExceptions = + [ + "System.Threading.Tasks", + "System.Threading.CancellationToken", + "System.Threading.CancellationTokenSource" + ]; + + public ScriptCompilationService(ILogger logger) + { + _logger = logger; + } + + /// + /// Validates that the script source code does not reference forbidden APIs. + /// Returns a list of violation messages, empty if clean. + /// + public IReadOnlyList ValidateTrustModel(string code) + { + var violations = new List(); + var tree = CSharpSyntaxTree.ParseText(code); + var root = tree.GetRoot(); + var text = root.ToFullString(); + + foreach (var ns in ForbiddenNamespaces) + { + if (text.Contains(ns, StringComparison.Ordinal)) + { + // Check if it matches an allowed exception + var isAllowed = AllowedExceptions.Any(allowed => + text.Contains(allowed, StringComparison.Ordinal) && + ns != allowed && + allowed.StartsWith(ns, StringComparison.Ordinal)); + + // More precise: check each occurrence + var idx = 0; + while ((idx = text.IndexOf(ns, idx, StringComparison.Ordinal)) >= 0) + { + var remainder = text.Substring(idx); + var matchesAllowed = AllowedExceptions.Any(a => + remainder.StartsWith(a, StringComparison.Ordinal)); + + if (!matchesAllowed) + { + violations.Add($"Forbidden API reference: '{ns}' at position {idx}"); + break; + } + idx += ns.Length; + } + } + } + + return violations; + } + + /// + /// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext + /// and parameters dictionary, and returns an object? result. + /// + public ScriptCompilationResult Compile(string scriptName, string code) + { + // Validate trust model + var violations = ValidateTrustModel(code); + if (violations.Count > 0) + { + _logger.LogWarning( + "Script {Script} failed trust validation: {Violations}", + scriptName, string.Join("; ", violations)); + return ScriptCompilationResult.Failed(violations); + } + + try + { + var scriptOptions = ScriptOptions.Default + .WithReferences( + typeof(object).Assembly, + typeof(Enumerable).Assembly, + typeof(Math).Assembly) + .WithImports( + "System", + "System.Collections.Generic", + "System.Linq", + "System.Threading.Tasks"); + + var script = CSharpScript.Create( + code, + scriptOptions, + globalsType: typeof(ScriptGlobals)); + + var diagnostics = script.Compile(); + var errors = diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage()) + .ToList(); + + if (errors.Count > 0) + { + _logger.LogWarning( + "Script {Script} compilation failed: {Errors}", + scriptName, string.Join("; ", errors)); + return ScriptCompilationResult.Failed(errors); + } + + _logger.LogDebug("Script {Script} compiled successfully", scriptName); + return ScriptCompilationResult.Succeeded(script); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName); + return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]); + } + } +} + +/// +/// Result of script compilation, containing either the compiled script or error messages. +/// +public class ScriptCompilationResult +{ + public bool IsSuccess { get; } + public Script? CompiledScript { get; } + public IReadOnlyList Errors { get; } + + private ScriptCompilationResult(bool success, Script? script, IReadOnlyList errors) + { + IsSuccess = success; + CompiledScript = script; + Errors = errors; + } + + public static ScriptCompilationResult Succeeded(Script script) => + new(true, script, []); + + public static ScriptCompilationResult Failed(IReadOnlyList errors) => + new(false, null, errors); +} + +/// +/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected +/// as the "Instance" global, and parameters are available via "Parameters". +/// +public class ScriptGlobals +{ + public ScriptRuntimeContext Instance { get; set; } = null!; + public IReadOnlyDictionary Parameters { get; set; } = + new Dictionary(); + public CancellationToken CancellationToken { get; set; } +} diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs new file mode 100644 index 0000000..5cc1105 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -0,0 +1,172 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.Instance; +using ScadaLink.Commons.Messages.ScriptExecution; + +namespace ScadaLink.SiteRuntime.Scripts; + +/// +/// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors. +/// Provides the API surface that user scripts interact with: +/// Instance.GetAttribute("name") +/// Instance.SetAttribute("name", value) +/// Instance.CallScript("scriptName", params) +/// Scripts.CallShared("scriptName", params) +/// +/// WP-20: Recursion Limit — call depth tracked and enforced. +/// +public class ScriptRuntimeContext +{ + private readonly IActorRef _instanceActor; + private readonly IActorRef _self; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly int _currentCallDepth; + private readonly int _maxCallDepth; + private readonly TimeSpan _askTimeout; + private readonly ILogger _logger; + private readonly string _instanceName; + + public ScriptRuntimeContext( + IActorRef instanceActor, + IActorRef self, + SharedScriptLibrary sharedScriptLibrary, + int currentCallDepth, + int maxCallDepth, + TimeSpan askTimeout, + string instanceName, + ILogger logger) + { + _instanceActor = instanceActor; + _self = self; + _sharedScriptLibrary = sharedScriptLibrary; + _currentCallDepth = currentCallDepth; + _maxCallDepth = maxCallDepth; + _askTimeout = askTimeout; + _instanceName = instanceName; + _logger = logger; + } + + /// + /// Gets the current value of an attribute from the Instance Actor. + /// Uses Ask pattern (system boundary between script execution and instance state). + /// + public async Task GetAttribute(string attributeName) + { + var correlationId = Guid.NewGuid().ToString(); + var request = new GetAttributeRequest( + correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow); + + var response = await _instanceActor.Ask(request, _askTimeout); + + if (!response.Found) + { + _logger.LogWarning( + "GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'", + attributeName, _instanceName); + } + + return response.Value; + } + + /// + /// Sets an attribute value. For data-connected attributes, forwards to DCL via Instance Actor. + /// For static attributes, updates in-memory and persists to SQLite via Instance Actor. + /// All mutations serialized through the Instance Actor mailbox. + /// + public void SetAttribute(string attributeName, string value) + { + var correlationId = Guid.NewGuid().ToString(); + var command = new SetStaticAttributeCommand( + correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow); + + // Tell (fire-and-forget) — mutation serialized through Instance Actor + _instanceActor.Tell(command); + } + + /// + /// Calls a sibling script on the same instance by name (Ask pattern). + /// WP-20: Enforces recursion limit. + /// WP-22: Uses Ask pattern for CallScript. + /// + public async Task CallScript(string scriptName, IReadOnlyDictionary? parameters = null) + { + var nextDepth = _currentCallDepth + 1; + if (nextDepth > _maxCallDepth) + { + var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " + + $"CallScript('{scriptName}') rejected at depth {nextDepth}."; + _logger.LogError(msg); + throw new InvalidOperationException(msg); + } + + var correlationId = Guid.NewGuid().ToString(); + var request = new ScriptCallRequest( + scriptName, + parameters, + nextDepth, + correlationId); + + // Ask the Instance Actor, which routes to the appropriate Script Actor + var result = await _instanceActor.Ask(request, _askTimeout); + + if (!result.Success) + { + throw new InvalidOperationException( + $"CallScript('{scriptName}') failed: {result.ErrorMessage}"); + } + + return result.ReturnValue; + } + + /// + /// Provides access to shared script execution via the Scripts property. + /// + public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger); + + /// + /// Helper class for Scripts.CallShared() syntax. + /// + public class ScriptCallHelper + { + private readonly SharedScriptLibrary _library; + private readonly ScriptRuntimeContext _context; + private readonly int _currentCallDepth; + private readonly int _maxCallDepth; + private readonly ILogger _logger; + + internal ScriptCallHelper( + SharedScriptLibrary library, + ScriptRuntimeContext context, + int currentCallDepth, + int maxCallDepth, + ILogger logger) + { + _library = library; + _context = context; + _currentCallDepth = currentCallDepth; + _maxCallDepth = maxCallDepth; + _logger = logger; + } + + /// + /// WP-17: Executes a shared script inline (direct method call, not actor message). + /// WP-20: Enforces recursion limit. + /// + public async Task CallShared( + string scriptName, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + { + var nextDepth = _currentCallDepth + 1; + if (nextDepth > _maxCallDepth) + { + var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " + + $"CallShared('{scriptName}') rejected at depth {nextDepth}."; + _logger.LogError(msg); + throw new InvalidOperationException(msg); + } + + return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken); + } + } +} diff --git a/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs b/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs new file mode 100644 index 0000000..3e502f2 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs @@ -0,0 +1,114 @@ +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging; + +namespace ScadaLink.SiteRuntime.Scripts; + +/// +/// WP-17: Shared Script Library — stores compiled shared script delegates in memory. +/// Shared scripts are compiled when received from central and executed inline +/// (direct method call, not actor message). NOT available on central. +/// WP-33: Recompiled on update when new artifacts arrive. +/// +public class SharedScriptLibrary +{ + private readonly ScriptCompilationService _compilationService; + private readonly ILogger _logger; + private readonly Dictionary> _compiledScripts = new(); + private readonly object _lock = new(); + + public SharedScriptLibrary( + ScriptCompilationService compilationService, + ILogger logger) + { + _compilationService = compilationService; + _logger = logger; + } + + /// + /// Compiles and registers a shared script. Replaces any existing script with the same name. + /// Returns true if compilation succeeded, false otherwise. + /// + public bool CompileAndRegister(string name, string code) + { + var result = _compilationService.Compile(name, code); + if (!result.IsSuccess) + { + _logger.LogWarning( + "Shared script '{Name}' failed to compile: {Errors}", + name, string.Join("; ", result.Errors)); + return false; + } + + lock (_lock) + { + _compiledScripts[name] = result.CompiledScript!; + } + + _logger.LogInformation("Shared script '{Name}' compiled and registered", name); + return true; + } + + /// + /// Removes a shared script from the library. + /// + public bool Remove(string name) + { + lock (_lock) + { + return _compiledScripts.Remove(name); + } + } + + /// + /// Executes a shared script inline with the given runtime context. + /// This is a direct method call, not an actor message — executes on the calling thread. + /// + public async Task ExecuteAsync( + string scriptName, + ScriptRuntimeContext context, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + { + Script script; + lock (_lock) + { + if (!_compiledScripts.TryGetValue(scriptName, out script!)) + { + throw new InvalidOperationException( + $"Shared script '{scriptName}' not found in library."); + } + } + + var globals = new ScriptGlobals + { + Instance = context, + Parameters = parameters ?? new Dictionary(), + CancellationToken = cancellationToken + }; + + var state = await script.RunAsync(globals, cancellationToken); + return state.ReturnValue; + } + + /// + /// Returns the names of all currently registered shared scripts. + /// + public IReadOnlyList GetRegisteredScriptNames() + { + lock (_lock) + { + return _compiledScripts.Keys.ToList(); + } + } + + /// + /// Returns whether a script with the given name is registered. + /// + public bool Contains(string name) + { + lock (_lock) + { + return _compiledScripts.ContainsKey(name); + } + } +} diff --git a/src/ScadaLink.SiteRuntime/ServiceCollectionExtensions.cs b/src/ScadaLink.SiteRuntime/ServiceCollectionExtensions.cs index 0d36b1f..eca99ce 100644 --- a/src/ScadaLink.SiteRuntime/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.SiteRuntime/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime; @@ -31,6 +32,12 @@ public static class ServiceCollectionExtensions services.AddHostedService(); + // WP-19: Script compilation service + services.AddSingleton(); + + // WP-17: Shared script library + services.AddSingleton(); + return services; } diff --git a/src/ScadaLink.SiteRuntime/SiteRuntimeOptions.cs b/src/ScadaLink.SiteRuntime/SiteRuntimeOptions.cs index a42945a..f84f9a8 100644 --- a/src/ScadaLink.SiteRuntime/SiteRuntimeOptions.cs +++ b/src/ScadaLink.SiteRuntime/SiteRuntimeOptions.cs @@ -17,4 +17,23 @@ public class SiteRuntimeOptions /// Default: 100ms. /// public int StartupBatchDelayMs { get; set; } = 100; + + /// + /// Maximum call depth for recursive script calls (CallScript/CallShared). + /// Default: 10. + /// + public int MaxScriptCallDepth { get; set; } = 10; + + /// + /// Default script execution timeout in seconds. + /// Default: 30 seconds. + /// + public int ScriptExecutionTimeoutSeconds { get; set; } = 30; + + /// + /// Per-subscriber buffer size for the site-wide Akka stream. + /// Slow subscribers drop oldest messages when buffer is full. + /// Default: 1000. + /// + public int StreamBufferSize { get; set; } = 1000; } diff --git a/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs b/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs new file mode 100644 index 0000000..fe493d5 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs @@ -0,0 +1,176 @@ +using Akka; +using Akka.Actor; +using Akka.Streams; +using Akka.Streams.Dsl; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Messages.Streaming; + +namespace ScadaLink.SiteRuntime.Streaming; + +/// +/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value +/// and alarm state changes. Instance Actors publish events via fire-and-forget Tell. +/// Subscribers get per-subscriber bounded buffers with drop-oldest overflow. +/// +/// Filterable by instance name for debug view (WP-25). +/// +public class SiteStreamManager +{ + private readonly ActorSystem _system; + private readonly int _bufferSize; + private readonly ILogger _logger; + private readonly object _lock = new(); + + private IActorRef? _sourceActor; + private readonly Dictionary _subscriptions = new(); + + public SiteStreamManager( + ActorSystem system, + SiteRuntimeOptions options, + ILogger logger) + { + _system = system; + _bufferSize = options.StreamBufferSize; + _logger = logger; + } + + /// + /// Initializes the stream source. Must be called after ActorSystem is ready. + /// + public void Initialize() + { + var materializer = _system.Materializer(); + + var source = Source.ActorRef( + _bufferSize, + OverflowStrategy.DropHead); + + var (actorRef, _) = source + .PreMaterialize(materializer); + + _sourceActor = actorRef; + + _logger.LogInformation( + "SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize); + } + + /// + /// Publishes an attribute value change to the stream. + /// Fire-and-forget — never blocks the calling actor. + /// + public void PublishAttributeValueChanged(AttributeValueChanged changed) + { + _sourceActor?.Tell(changed); + + // Also forward to filtered subscribers + ForwardToSubscribers(changed.InstanceUniqueName, changed); + } + + /// + /// Publishes an alarm state change to the stream. + /// Fire-and-forget — never blocks the calling actor. + /// + public void PublishAlarmStateChanged(AlarmStateChanged changed) + { + _sourceActor?.Tell(changed); + + // Also forward to filtered subscribers + ForwardToSubscribers(changed.InstanceUniqueName, changed); + } + + /// + /// WP-25: Subscribe to events for a specific instance (debug view). + /// Returns a subscription ID for unsubscribing. + /// + public string Subscribe(string instanceName, IActorRef subscriber) + { + var subscriptionId = Guid.NewGuid().ToString(); + + lock (_lock) + { + _subscriptions[subscriptionId] = new SubscriptionInfo( + instanceName, subscriber, DateTimeOffset.UtcNow); + } + + _logger.LogDebug( + "Subscriber {SubscriptionId} registered for instance {Instance}", + subscriptionId, instanceName); + + return subscriptionId; + } + + /// + /// WP-25: Unsubscribe from instance events. + /// + public bool Unsubscribe(string subscriptionId) + { + lock (_lock) + { + var removed = _subscriptions.Remove(subscriptionId); + if (removed) + { + _logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId); + } + return removed; + } + } + + /// + /// WP-25: Remove all subscriptions for a specific subscriber actor. + /// Called when connection is interrupted. + /// + public void RemoveSubscriber(IActorRef subscriber) + { + lock (_lock) + { + var toRemove = _subscriptions + .Where(kvp => kvp.Value.Subscriber.Equals(subscriber)) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var id in toRemove) + { + _subscriptions.Remove(id); + } + + if (toRemove.Count > 0) + { + _logger.LogDebug( + "Removed {Count} subscriptions for disconnected subscriber", toRemove.Count); + } + } + } + + /// + /// Returns the count of active subscriptions (for diagnostics/testing). + /// + public int SubscriptionCount + { + get { lock (_lock) { return _subscriptions.Count; } } + } + + private void ForwardToSubscribers(string instanceName, object message) + { + lock (_lock) + { + foreach (var sub in _subscriptions.Values) + { + if (sub.InstanceName == instanceName) + { + // Fire-and-forget to subscriber + sub.Subscriber.Tell(message); + } + } + } + } + + private record SubscriptionInfo( + string InstanceName, + IActorRef Subscriber, + DateTimeOffset SubscribedAt); +} + +/// +/// Marker interface for events published to the site stream. +/// +public interface ISiteStreamEvent { } diff --git a/tests/ScadaLink.Communication.Tests/CentralCommunicationActorTests.cs b/tests/ScadaLink.Communication.Tests/CentralCommunicationActorTests.cs new file mode 100644 index 0000000..e28d900 --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/CentralCommunicationActorTests.cs @@ -0,0 +1,105 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using ScadaLink.Commons.Messages.Communication; +using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Messages.DebugView; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Communication.Actors; + +namespace ScadaLink.Communication.Tests; + +/// +/// WP-4: Tests for CentralCommunicationActor message routing. +/// WP-5: Tests for connection failure and failover handling. +/// +public class CentralCommunicationActorTests : TestKit +{ + public CentralCommunicationActorTests() + : base(@"akka.loglevel = DEBUG") + { + } + + [Fact] + public void RegisterSite_AllowsMessageRouting() + { + var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor())); + + // Register a site pointing to the test probe + var probe = CreateTestProbe(); + centralActor.Tell(new RegisterSite("site1", probe.Ref.Path.ToString())); + + // Send a message to the site + var command = new DeployInstanceCommand( + "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); + centralActor.Tell(new SiteEnvelope("site1", command)); + + // The probe should receive the inner message (not the envelope) + probe.ExpectMsg(msg => msg.DeploymentId == "dep1"); + } + + [Fact] + public void UnregisteredSite_MessageIsDropped() + { + var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor())); + + var command = new DeployInstanceCommand( + "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); + centralActor.Tell(new SiteEnvelope("unknown-site", command)); + + // No crash, no response — the ask will timeout on the caller side + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + } + + [Fact] + public void ConnectionLost_DebugStreamsKilled() + { + var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor())); + var siteProbe = CreateTestProbe(); + + // Register site + centralActor.Tell(new RegisterSite("site1", siteProbe.Ref.Path.ToString())); + + // Subscribe to debug view (this tracks the subscription) + var subscriberProbe = CreateTestProbe(); + var subRequest = new SubscribeDebugViewRequest("inst1", "corr-123"); + centralActor.Tell(new SiteEnvelope("site1", subRequest), subscriberProbe.Ref); + + // Simulate site disconnection + centralActor.Tell(new ConnectionStateChanged("site1", false, DateTimeOffset.UtcNow)); + + // The subscriber should receive a DebugStreamTerminated notification + subscriberProbe.ExpectMsg( + msg => msg.SiteId == "site1" && msg.CorrelationId == "corr-123"); + } + + [Fact] + public void ConnectionLost_SiteSelectionRemoved() + { + var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor())); + var siteProbe = CreateTestProbe(); + + centralActor.Tell(new RegisterSite("site1", siteProbe.Ref.Path.ToString())); + + // Disconnect + centralActor.Tell(new ConnectionStateChanged("site1", false, DateTimeOffset.UtcNow)); + + // Sending a message to the disconnected site should be dropped + centralActor.Tell(new SiteEnvelope("site1", + new DeployInstanceCommand("dep2", "inst2", "hash2", "{}", "admin", DateTimeOffset.UtcNow))); + + siteProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + } + + [Fact] + public void Heartbeat_ForwardedToParent() + { + var parentProbe = CreateTestProbe(); + var centralActor = parentProbe.ChildActorOf( + Props.Create(() => new CentralCommunicationActor())); + + var heartbeat = new HeartbeatMessage("site1", "host1", true, DateTimeOffset.UtcNow); + centralActor.Tell(heartbeat); + + parentProbe.ExpectMsg(msg => msg.SiteId == "site1"); + } +} diff --git a/tests/ScadaLink.Communication.Tests/CommunicationOptionsTests.cs b/tests/ScadaLink.Communication.Tests/CommunicationOptionsTests.cs new file mode 100644 index 0000000..bee4ee5 --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/CommunicationOptionsTests.cs @@ -0,0 +1,61 @@ +namespace ScadaLink.Communication.Tests; + +/// +/// WP-2: Tests for per-pattern timeout configuration. +/// +public class CommunicationOptionsTests +{ + [Fact] + public void DefaultTimeouts_AreReasonable() + { + var options = new CommunicationOptions(); + + Assert.Equal(TimeSpan.FromMinutes(2), options.DeploymentTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), options.LifecycleTimeout); + Assert.Equal(TimeSpan.FromMinutes(1), options.ArtifactDeploymentTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), options.QueryTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), options.IntegrationTimeout); + Assert.Equal(TimeSpan.FromSeconds(10), options.DebugViewTimeout); + Assert.Equal(TimeSpan.FromSeconds(10), options.HealthReportTimeout); + } + + [Fact] + public void TransportHeartbeat_HasExplicitDefaults() + { + var options = new CommunicationOptions(); + + // WP-3: Transport heartbeat is explicitly configured, not framework defaults + Assert.Equal(TimeSpan.FromSeconds(5), options.TransportHeartbeatInterval); + Assert.Equal(TimeSpan.FromSeconds(15), options.TransportFailureThreshold); + } + + [Fact] + public void DeploymentTimeout_IsLongestPattern() + { + var options = new CommunicationOptions(); + + Assert.True(options.DeploymentTimeout > options.LifecycleTimeout); + Assert.True(options.DeploymentTimeout > options.QueryTimeout); + Assert.True(options.DeploymentTimeout > options.IntegrationTimeout); + } + + [Fact] + public void AllTimeouts_AreConfigurable() + { + var options = new CommunicationOptions + { + DeploymentTimeout = TimeSpan.FromMinutes(5), + LifecycleTimeout = TimeSpan.FromMinutes(1), + ArtifactDeploymentTimeout = TimeSpan.FromMinutes(3), + QueryTimeout = TimeSpan.FromMinutes(1), + IntegrationTimeout = TimeSpan.FromMinutes(1), + DebugViewTimeout = TimeSpan.FromSeconds(30), + HealthReportTimeout = TimeSpan.FromSeconds(30), + TransportHeartbeatInterval = TimeSpan.FromSeconds(2), + TransportFailureThreshold = TimeSpan.FromSeconds(10) + }; + + Assert.Equal(TimeSpan.FromMinutes(5), options.DeploymentTimeout); + Assert.Equal(TimeSpan.FromSeconds(2), options.TransportHeartbeatInterval); + } +} diff --git a/tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs b/tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs new file mode 100644 index 0000000..dca586b --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace ScadaLink.Communication.Tests; + +/// +/// WP-2: Tests for CommunicationService initialization and state. +/// +public class CommunicationServiceTests +{ + [Fact] + public async Task BeforeInitialization_ThrowsOnUsage() + { + var options = Options.Create(new CommunicationOptions()); + var logger = NullLogger.Instance; + var service = new CommunicationService(options, logger); + + // CommunicationService requires SetCommunicationActor before use + await Assert.ThrowsAsync(() => + service.DeployInstanceAsync("site1", + new Commons.Messages.Deployment.DeployInstanceCommand( + "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow))); + } + + [Fact] + public void UnsubscribeDebugView_IsTellNotAsk() + { + // Verify the method signature is void (fire-and-forget Tell pattern) + var method = typeof(CommunicationService).GetMethod("UnsubscribeDebugView"); + Assert.NotNull(method); + Assert.Equal(typeof(void), method!.ReturnType); + } +} diff --git a/tests/ScadaLink.Communication.Tests/MessageContractTests.cs b/tests/ScadaLink.Communication.Tests/MessageContractTests.cs new file mode 100644 index 0000000..247d914 --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/MessageContractTests.cs @@ -0,0 +1,102 @@ +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Messages.RemoteQuery; + +namespace ScadaLink.Communication.Tests; + +/// +/// WP-1: Tests that message contracts have correlation IDs and proper structure. +/// +public class MessageContractTests +{ + [Fact] + public void IntegrationCallRequest_HasCorrelationId() + { + var msg = new IntegrationCallRequest( + "corr-123", "site1", "inst1", "ExtSys1", "GetData", + new Dictionary(), DateTimeOffset.UtcNow); + + Assert.Equal("corr-123", msg.CorrelationId); + } + + [Fact] + public void IntegrationCallResponse_HasCorrelationId() + { + var msg = new IntegrationCallResponse( + "corr-123", "site1", true, "{}", null, DateTimeOffset.UtcNow); + + Assert.Equal("corr-123", msg.CorrelationId); + } + + [Fact] + public void EventLogQueryRequest_HasCorrelationId() + { + var msg = new EventLogQueryRequest( + "corr-456", "site1", null, null, null, null, null, null, null, 25, DateTimeOffset.UtcNow); + + Assert.Equal("corr-456", msg.CorrelationId); + } + + [Fact] + public void EventLogQueryResponse_HasCorrelationId() + { + var msg = new EventLogQueryResponse( + "corr-456", "site1", [], null, false, true, null, DateTimeOffset.UtcNow); + + Assert.Equal("corr-456", msg.CorrelationId); + } + + [Fact] + public void ParkedMessageQueryRequest_HasCorrelationId() + { + var msg = new ParkedMessageQueryRequest( + "corr-789", "site1", 1, 25, DateTimeOffset.UtcNow); + + Assert.Equal("corr-789", msg.CorrelationId); + } + + [Fact] + public void ParkedMessageQueryResponse_HasCorrelationId() + { + var msg = new ParkedMessageQueryResponse( + "corr-789", "site1", [], 0, 1, 25, true, null, DateTimeOffset.UtcNow); + + Assert.Equal("corr-789", msg.CorrelationId); + } + + [Fact] + public void AllMessagePatterns_ExistAsRecordTypes() + { + // Verify all 8 patterns have proper request/response types + // Pattern 1: Deployment + Assert.True(typeof(Commons.Messages.Deployment.DeployInstanceCommand).IsValueType == false); + Assert.True(typeof(Commons.Messages.Deployment.DeploymentStatusResponse).IsValueType == false); + + // Pattern 2: Lifecycle + Assert.True(typeof(Commons.Messages.Lifecycle.DisableInstanceCommand).IsValueType == false); + Assert.True(typeof(Commons.Messages.Lifecycle.InstanceLifecycleResponse).IsValueType == false); + + // Pattern 3: Artifacts + Assert.True(typeof(Commons.Messages.Artifacts.DeployArtifactsCommand).IsValueType == false); + Assert.True(typeof(Commons.Messages.Artifacts.ArtifactDeploymentResponse).IsValueType == false); + + // Pattern 4: Integration + Assert.True(typeof(IntegrationCallRequest).IsValueType == false); + Assert.True(typeof(IntegrationCallResponse).IsValueType == false); + + // Pattern 5: Debug View + Assert.True(typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest).IsValueType == false); + Assert.True(typeof(Commons.Messages.DebugView.DebugViewSnapshot).IsValueType == false); + + // Pattern 6: Health + Assert.True(typeof(Commons.Messages.Health.SiteHealthReport).IsValueType == false); + + // Pattern 7: Remote Queries + Assert.True(typeof(EventLogQueryRequest).IsValueType == false); + Assert.True(typeof(EventLogQueryResponse).IsValueType == false); + Assert.True(typeof(ParkedMessageQueryRequest).IsValueType == false); + Assert.True(typeof(ParkedMessageQueryResponse).IsValueType == false); + + // Pattern 8: Heartbeat + Assert.True(typeof(Commons.Messages.Health.HeartbeatMessage).IsValueType == false); + } +} diff --git a/tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj b/tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj index 8f83dbe..9db2223 100644 --- a/tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj +++ b/tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -9,8 +9,10 @@ + + @@ -21,6 +23,7 @@ + - \ No newline at end of file + diff --git a/tests/ScadaLink.Communication.Tests/SiteCommunicationActorTests.cs b/tests/ScadaLink.Communication.Tests/SiteCommunicationActorTests.cs new file mode 100644 index 0000000..d7c48c1 --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/SiteCommunicationActorTests.cs @@ -0,0 +1,104 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Messages.Lifecycle; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Messages.RemoteQuery; +using ScadaLink.Communication.Actors; + +namespace ScadaLink.Communication.Tests; + +/// +/// WP-4: Tests for SiteCommunicationActor message routing to local actors. +/// +public class SiteCommunicationActorTests : TestKit +{ + private readonly CommunicationOptions _options = new(); + + public SiteCommunicationActorTests() + : base(@"akka.loglevel = DEBUG") + { + } + + [Fact] + public void DeployCommand_ForwardedToDeploymentManager() + { + var dmProbe = CreateTestProbe(); + var siteActor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref))); + + var command = new DeployInstanceCommand( + "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); + siteActor.Tell(command); + + dmProbe.ExpectMsg(msg => msg.DeploymentId == "dep1"); + } + + [Fact] + public void LifecycleCommands_ForwardedToDeploymentManager() + { + var dmProbe = CreateTestProbe(); + var siteActor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref))); + + siteActor.Tell(new DisableInstanceCommand("cmd1", "inst1", DateTimeOffset.UtcNow)); + dmProbe.ExpectMsg(); + + siteActor.Tell(new EnableInstanceCommand("cmd2", "inst1", DateTimeOffset.UtcNow)); + dmProbe.ExpectMsg(); + + siteActor.Tell(new DeleteInstanceCommand("cmd3", "inst1", DateTimeOffset.UtcNow)); + dmProbe.ExpectMsg(); + } + + [Fact] + public void IntegrationCall_WithoutHandler_ReturnsFailure() + { + var dmProbe = CreateTestProbe(); + var siteActor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref))); + + var request = new IntegrationCallRequest( + "corr1", "site1", "inst1", "ExtSys1", "GetData", + new Dictionary(), DateTimeOffset.UtcNow); + + siteActor.Tell(request); + + ExpectMsg(msg => + !msg.Success && msg.ErrorMessage == "Integration handler not available"); + } + + [Fact] + public void IntegrationCall_WithHandler_ForwardedToHandler() + { + var dmProbe = CreateTestProbe(); + var handlerProbe = CreateTestProbe(); + var siteActor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref))); + + // Register integration handler + siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.Integration, handlerProbe.Ref)); + + var request = new IntegrationCallRequest( + "corr1", "site1", "inst1", "ExtSys1", "GetData", + new Dictionary(), DateTimeOffset.UtcNow); + + siteActor.Tell(request); + handlerProbe.ExpectMsg(msg => msg.CorrelationId == "corr1"); + } + + [Fact] + public void EventLogQuery_WithoutHandler_ReturnsFailure() + { + var dmProbe = CreateTestProbe(); + var siteActor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref))); + + var request = new EventLogQueryRequest( + "corr1", "site1", null, null, null, null, null, null, null, 25, DateTimeOffset.UtcNow); + + siteActor.Tell(request); + + ExpectMsg(msg => !msg.Success); + } +} diff --git a/tests/ScadaLink.Communication.Tests/UnitTest1.cs b/tests/ScadaLink.Communication.Tests/UnitTest1.cs deleted file mode 100644 index b8d9876..0000000 --- a/tests/ScadaLink.Communication.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ScadaLink.Communication.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionActorTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionActorTests.cs new file mode 100644 index 0000000..f4a6b82 --- /dev/null +++ b/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionActorTests.cs @@ -0,0 +1,144 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using NSubstitute; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Messages.DataConnection; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.DataConnectionLayer.Actors; + +namespace ScadaLink.DataConnectionLayer.Tests; + +/// +/// WP-6: Tests for DataConnectionActor Become/Stash state machine. +/// WP-9: Auto-reconnect and bad quality tests. +/// WP-10: Transparent re-subscribe tests. +/// WP-11: Write-back support tests. +/// WP-12: Tag path resolution with retry tests. +/// WP-13: Health reporting tests. +/// WP-14: Subscription lifecycle tests. +/// +public class DataConnectionActorTests : TestKit +{ + private readonly IDataConnection _mockAdapter; + private readonly DataConnectionOptions _options; + + public DataConnectionActorTests() + : base(@"akka.loglevel = DEBUG") + { + _mockAdapter = Substitute.For(); + _options = new DataConnectionOptions + { + ReconnectInterval = TimeSpan.FromMilliseconds(100), + TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200), + WriteTimeout = TimeSpan.FromSeconds(5) + }; + } + + private IActorRef CreateConnectionActor(string name = "test-conn") + { + return Sys.ActorOf(Props.Create(() => + new DataConnectionActor(name, _mockAdapter, _options)), name); + } + + [Fact] + public void WP6_StartsInConnectingState_AttemptsConnect() + { + _mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + var actor = CreateConnectionActor(); + + // Give it time to attempt connection + AwaitCondition(() => + _mockAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"), + TimeSpan.FromSeconds(2)); + } + + [Fact] + public void WP6_ConnectingState_StashesSubscribeRequests() + { + // Make connect hang so we stay in Connecting + var tcs = new TaskCompletionSource(); + _mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(tcs.Task); + + var actor = CreateConnectionActor("stash-test"); + + // Send subscribe while connecting — should be stashed + actor.Tell(new SubscribeTagsRequest( + "corr1", "inst1", "stash-test", ["tag1"], DateTimeOffset.UtcNow)); + + // No response yet (stashed) + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + + // Complete connection — should unstash and process + _mockAdapter.SubscribeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("sub-001"); + + tcs.SetResult(); + + // Now we should get the response + ExpectMsg(TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task WP11_ConnectedState_Write_ReturnsResult() + { + _mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + _mockAdapter.WriteAsync("tag1", 42, Arg.Any()) + .Returns(new WriteResult(true, null)); + + var actor = CreateConnectionActor("write-test"); + + // Wait for connected state + AwaitCondition(() => + _mockAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"), + TimeSpan.FromSeconds(2)); + + // Small delay for state transition + await Task.Delay(200); + + actor.Tell(new WriteTagRequest("corr1", "write-test", "tag1", 42, DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.True(response.Success); + } + + [Fact] + public async Task WP11_Write_Failure_ReturnedSynchronously() + { + _mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + _mockAdapter.WriteAsync("tag1", 42, Arg.Any()) + .Returns(new WriteResult(false, "Device offline")); + + var actor = CreateConnectionActor("write-fail-test"); + + await Task.Delay(300); + + actor.Tell(new WriteTagRequest("corr1", "write-fail-test", "tag1", 42, DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.False(response.Success); + Assert.Equal("Device offline", response.ErrorMessage); + } + + [Fact] + public async Task WP13_HealthReport_ReturnsConnectionStatus() + { + _mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + _mockAdapter.Status.Returns(ConnectionHealth.Connected); + + var actor = CreateConnectionActor("health-test"); + + await Task.Delay(300); + + actor.Tell(new DataConnectionActor.GetHealthReport()); + + var report = ExpectMsg(TimeSpan.FromSeconds(2)); + Assert.Equal("health-test", report.ConnectionName); + Assert.Equal(ConnectionHealth.Connected, report.Status); + } +} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs new file mode 100644 index 0000000..b68d333 --- /dev/null +++ b/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.DataConnectionLayer.Adapters; + +namespace ScadaLink.DataConnectionLayer.Tests; + +/// +/// WP-34: Tests for protocol extensibility via DataConnectionFactory. +/// +public class DataConnectionFactoryTests +{ + [Fact] + public void Create_OpcUa_ReturnsOpcUaAdapter() + { + var factory = new DataConnectionFactory(NullLoggerFactory.Instance); + + var connection = factory.Create("OpcUa", new Dictionary()); + + Assert.IsType(connection); + } + + [Fact] + public void Create_LmxProxy_ReturnsLmxProxyAdapter() + { + var factory = new DataConnectionFactory(NullLoggerFactory.Instance); + + var connection = factory.Create("LmxProxy", new Dictionary()); + + Assert.IsType(connection); + } + + [Fact] + public void Create_CaseInsensitive() + { + var factory = new DataConnectionFactory(NullLoggerFactory.Instance); + + var connection = factory.Create("opcua", new Dictionary()); + + Assert.IsType(connection); + } + + [Fact] + public void Create_UnknownProtocol_Throws() + { + var factory = new DataConnectionFactory(NullLoggerFactory.Instance); + + var ex = Assert.Throws(() => + factory.Create("UnknownProtocol", new Dictionary())); + + Assert.Contains("Unknown protocol type", ex.Message); + Assert.Contains("OpcUa", ex.Message); + } + + [Fact] + public void RegisterAdapter_ExtendsFactory() + { + var factory = new DataConnectionFactory(NullLoggerFactory.Instance); + + // WP-34: Adding new protocol = register adapter + factory.RegisterAdapter("Custom", _ => new OpcUaDataConnection( + new DefaultOpcUaClientFactory(), NullLogger.Instance)); + + var connection = factory.Create("Custom", new Dictionary()); + Assert.NotNull(connection); + } +} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs new file mode 100644 index 0000000..82b0684 --- /dev/null +++ b/tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs @@ -0,0 +1,77 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using NSubstitute; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Messages.DataConnection; +using ScadaLink.DataConnectionLayer.Actors; + +namespace ScadaLink.DataConnectionLayer.Tests; + +/// +/// WP-34: Tests for DataConnectionManagerActor routing and lifecycle. +/// +public class DataConnectionManagerActorTests : TestKit +{ + private readonly IDataConnectionFactory _mockFactory; + private readonly DataConnectionOptions _options; + + public DataConnectionManagerActorTests() + : base(@"akka.loglevel = DEBUG") + { + _mockFactory = Substitute.For(); + _options = new DataConnectionOptions + { + ReconnectInterval = TimeSpan.FromMilliseconds(100), + TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200) + }; + } + + [Fact] + public void WriteToUnknownConnection_ReturnsError() + { + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_mockFactory, _options))); + + manager.Tell(new WriteTagRequest( + "corr1", "nonexistent", "tag1", 42, DateTimeOffset.UtcNow)); + + var response = ExpectMsg(); + Assert.False(response.Success); + Assert.Contains("Unknown connection", response.ErrorMessage); + } + + [Fact] + public void SubscribeToUnknownConnection_ReturnsError() + { + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_mockFactory, _options))); + + manager.Tell(new SubscribeTagsRequest( + "corr1", "inst1", "nonexistent", ["tag1"], DateTimeOffset.UtcNow)); + + var response = ExpectMsg(); + Assert.False(response.Success); + Assert.Contains("Unknown connection", response.ErrorMessage); + } + + [Fact] + public void CreateConnection_UsesFactory() + { + var mockAdapter = Substitute.For(); + mockAdapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + _mockFactory.Create("OpcUa", Arg.Any>()) + .Returns(mockAdapter); + + var manager = Sys.ActorOf(Props.Create(() => + new DataConnectionManagerActor(_mockFactory, _options))); + + manager.Tell(new CreateConnectionCommand( + "conn1", "OpcUa", new Dictionary())); + + // Factory should have been called + AwaitCondition(() => + _mockFactory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), + TimeSpan.FromSeconds(2)); + } +} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs new file mode 100644 index 0000000..b07eaed --- /dev/null +++ b/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.DataConnectionLayer.Adapters; + +namespace ScadaLink.DataConnectionLayer.Tests; + +/// +/// WP-8: Tests for LmxProxy adapter. +/// +public class LmxProxyDataConnectionTests +{ + private readonly ILmxProxyClient _mockClient; + private readonly ILmxProxyClientFactory _mockFactory; + private readonly LmxProxyDataConnection _adapter; + + public LmxProxyDataConnectionTests() + { + _mockClient = Substitute.For(); + _mockFactory = Substitute.For(); + _mockFactory.Create().Returns(_mockClient); + _adapter = new LmxProxyDataConnection(_mockFactory, NullLogger.Instance); + } + + [Fact] + public async Task Connect_OpensSessionWithHostAndPort() + { + _mockClient.OpenSessionAsync("myhost", 5001, Arg.Any()) + .Returns("session-123"); + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["Host"] = "myhost", + ["Port"] = "5001" + }); + + Assert.Equal(ConnectionHealth.Connected, _adapter.Status); + await _mockClient.Received(1).OpenSessionAsync("myhost", 5001, Arg.Any()); + } + + [Fact] + public async Task Disconnect_ClosesSession() + { + _mockClient.IsConnected.Returns(true); + _mockClient.OpenSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("session-123"); + + await _adapter.ConnectAsync(new Dictionary()); + await _adapter.DisconnectAsync(); + + Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); + await _mockClient.Received(1).CloseSessionAsync(Arg.Any()); + } + + [Fact] + public async Task Write_Success_ReturnsGoodResult() + { + _mockClient.IsConnected.Returns(true); + _mockClient.OpenSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("session-123"); + _mockClient.WriteTagAsync("Tag1", 42, Arg.Any()) + .Returns(true); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.WriteAsync("Tag1", 42); + + Assert.True(result.Success); + } + + [Fact] + public async Task Write_Failure_ReturnsError() + { + _mockClient.IsConnected.Returns(true); + _mockClient.OpenSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("session-123"); + _mockClient.WriteTagAsync("Tag1", 42, Arg.Any()) + .Returns(false); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.WriteAsync("Tag1", 42); + + Assert.False(result.Success); + Assert.Equal("LmxProxy write failed", result.ErrorMessage); + } + + [Fact] + public async Task Read_Good_ReturnsValue() + { + _mockClient.IsConnected.Returns(true); + _mockClient.OpenSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("session-123"); + _mockClient.ReadTagAsync("Tag1", Arg.Any()) + .Returns((42.5, DateTime.UtcNow, true)); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.ReadAsync("Tag1"); + + Assert.True(result.Success); + Assert.Equal(42.5, result.Value!.Value); + } + + [Fact] + public async Task Read_Bad_ReturnsFailure() + { + _mockClient.IsConnected.Returns(true); + _mockClient.OpenSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("session-123"); + _mockClient.ReadTagAsync("Tag1", Arg.Any()) + .Returns((null, DateTime.UtcNow, false)); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.ReadAsync("Tag1"); + + Assert.False(result.Success); + } + + [Fact] + public async Task NotConnected_ThrowsOnOperations() + { + _mockClient.IsConnected.Returns(false); + + await Assert.ThrowsAsync(() => + _adapter.ReadAsync("tag1")); + } +} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs new file mode 100644 index 0000000..b4bdb04 --- /dev/null +++ b/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.DataConnectionLayer.Adapters; + +namespace ScadaLink.DataConnectionLayer.Tests; + +/// +/// WP-7: Tests for OPC UA adapter. +/// +public class OpcUaDataConnectionTests +{ + private readonly IOpcUaClient _mockClient; + private readonly IOpcUaClientFactory _mockFactory; + private readonly OpcUaDataConnection _adapter; + + public OpcUaDataConnectionTests() + { + _mockClient = Substitute.For(); + _mockFactory = Substitute.For(); + _mockFactory.Create().Returns(_mockClient); + _adapter = new OpcUaDataConnection(_mockFactory, NullLogger.Instance); + } + + [Fact] + public async Task Connect_SetsStatusToConnected() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["EndpointUrl"] = "opc.tcp://localhost:4840" + }); + + Assert.Equal(ConnectionHealth.Connected, _adapter.Status); + await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any()); + } + + [Fact] + public async Task Disconnect_SetsStatusToDisconnected() + { + _mockClient.IsConnected.Returns(true); + await _adapter.ConnectAsync(new Dictionary()); + await _adapter.DisconnectAsync(); + + Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); + } + + [Fact] + public async Task Subscribe_DelegatesAndReturnsId() + { + _mockClient.IsConnected.Returns(true); + _mockClient.CreateSubscriptionAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns("sub-001"); + + await _adapter.ConnectAsync(new Dictionary()); + var subId = await _adapter.SubscribeAsync("ns=2;s=Tag1", (_, _) => { }); + + Assert.Equal("sub-001", subId); + } + + [Fact] + public async Task Write_Success_ReturnsGoodResult() + { + _mockClient.IsConnected.Returns(true); + _mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any()) + .Returns((uint)0); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42); + + Assert.True(result.Success); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task Write_Failure_ReturnsError() + { + _mockClient.IsConnected.Returns(true); + _mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any()) + .Returns(0x80000000u); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42); + + Assert.False(result.Success); + Assert.Contains("0x80000000", result.ErrorMessage); + } + + [Fact] + public async Task Read_BadStatus_ReturnsBadResult() + { + _mockClient.IsConnected.Returns(true); + _mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any()) + .Returns((null, DateTime.UtcNow, 0x80000000u)); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.ReadAsync("ns=2;s=Tag1"); + + Assert.False(result.Success); + } + + [Fact] + public async Task Read_GoodStatus_ReturnsValue() + { + _mockClient.IsConnected.Returns(true); + _mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any()) + .Returns((42.5, DateTime.UtcNow, 0u)); + + await _adapter.ConnectAsync(new Dictionary()); + var result = await _adapter.ReadAsync("ns=2;s=Tag1"); + + Assert.True(result.Success); + Assert.NotNull(result.Value); + Assert.Equal(42.5, result.Value!.Value); + Assert.Equal(QualityCode.Good, result.Value.Quality); + } + + [Fact] + public async Task ReadBatch_ReadsAllTags() + { + _mockClient.IsConnected.Returns(true); + _mockClient.ReadValueAsync(Arg.Any(), Arg.Any()) + .Returns((1.0, DateTime.UtcNow, 0u)); + + await _adapter.ConnectAsync(new Dictionary()); + var results = await _adapter.ReadBatchAsync(["tag1", "tag2", "tag3"]); + + Assert.Equal(3, results.Count); + Assert.All(results.Values, r => Assert.True(r.Success)); + } + + [Fact] + public async Task NotConnected_ThrowsOnOperations() + { + _mockClient.IsConnected.Returns(false); + + await Assert.ThrowsAsync(() => + _adapter.ReadAsync("tag1")); + } + + [Fact] + public async Task DisposeAsync_CleansUp() + { + _mockClient.IsConnected.Returns(true); + await _adapter.ConnectAsync(new Dictionary()); + await _adapter.DisposeAsync(); + + Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); + } +} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj b/tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj index f86897a..150c913 100644 --- a/tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj +++ b/tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -9,8 +9,10 @@ + + @@ -21,6 +23,7 @@ + - \ No newline at end of file + diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/UnitTest1.cs b/tests/ScadaLink.DataConnectionLayer.Tests/UnitTest1.cs deleted file mode 100644 index 90b73ed..0000000 --- a/tests/ScadaLink.DataConnectionLayer.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ScadaLink.DataConnectionLayer.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/ScadaLink.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs new file mode 100644 index 0000000..6d37f99 --- /dev/null +++ b/tests/ScadaLink.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.HealthMonitoring.Tests; + +/// +/// A simple fake TimeProvider for testing that allows advancing time manually. +/// +internal sealed class TestTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + public TestTimeProvider(DateTimeOffset startTime) + { + _utcNow = startTime; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) => _utcNow += duration; +} + +public class CentralHealthAggregatorTests +{ + private readonly TestTimeProvider _timeProvider; + private readonly CentralHealthAggregator _aggregator; + + public CentralHealthAggregatorTests() + { + _timeProvider = new TestTimeProvider(DateTimeOffset.UtcNow); + var options = Options.Create(new HealthMonitoringOptions + { + OfflineTimeout = TimeSpan.FromSeconds(60) + }); + _aggregator = new CentralHealthAggregator( + options, + NullLogger.Instance, + _timeProvider); + } + + private static SiteHealthReport MakeReport(string siteId, long seq) => + new( + SiteId: siteId, + SequenceNumber: seq, + ReportTimestamp: DateTimeOffset.UtcNow, + DataConnectionStatuses: new Dictionary(), + TagResolutionCounts: new Dictionary(), + ScriptErrorCount: 0, + AlarmEvaluationErrorCount: 0, + StoreAndForwardBufferDepths: new Dictionary(), + DeadLetterCount: 0); + + [Fact] + public void ProcessReport_StoresState_ForNewSite() + { + _aggregator.ProcessReport(MakeReport("site-1", 1)); + + var state = _aggregator.GetSiteState("site-1"); + Assert.NotNull(state); + Assert.True(state.IsOnline); + Assert.Equal(1, state.LastSequenceNumber); + } + + [Fact] + public void ProcessReport_UpdatesState_WhenSequenceIncreases() + { + _aggregator.ProcessReport(MakeReport("site-1", 1)); + _aggregator.ProcessReport(MakeReport("site-1", 2)); + + var state = _aggregator.GetSiteState("site-1"); + Assert.Equal(2, state!.LastSequenceNumber); + } + + [Fact] + public void ProcessReport_RejectsStaleReport_WhenSequenceNotGreater() + { + _aggregator.ProcessReport(MakeReport("site-1", 5)); + _aggregator.ProcessReport(MakeReport("site-1", 3)); + + var state = _aggregator.GetSiteState("site-1"); + Assert.Equal(5, state!.LastSequenceNumber); + } + + [Fact] + public void ProcessReport_RejectsEqualSequence() + { + _aggregator.ProcessReport(MakeReport("site-1", 5)); + _aggregator.ProcessReport(MakeReport("site-1", 5)); + + var state = _aggregator.GetSiteState("site-1"); + Assert.Equal(5, state!.LastSequenceNumber); + } + + [Fact] + public void OfflineDetection_SiteGoesOffline_WhenNoReportWithinTimeout() + { + _aggregator.ProcessReport(MakeReport("site-1", 1)); + Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); + + // Advance past the offline timeout + _timeProvider.Advance(TimeSpan.FromSeconds(61)); + _aggregator.CheckForOfflineSites(); + + Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline); + } + + [Fact] + public void OnlineRecovery_SiteComesBackOnline_WhenReportReceived() + { + _aggregator.ProcessReport(MakeReport("site-1", 1)); + + // Go offline + _timeProvider.Advance(TimeSpan.FromSeconds(61)); + _aggregator.CheckForOfflineSites(); + Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline); + + // Receive new report → back online + _aggregator.ProcessReport(MakeReport("site-1", 2)); + Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); + } + + [Fact] + public void OfflineDetection_SiteRemainsOnline_WhenReportWithinTimeout() + { + _aggregator.ProcessReport(MakeReport("site-1", 1)); + + _timeProvider.Advance(TimeSpan.FromSeconds(30)); + _aggregator.CheckForOfflineSites(); + + Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); + } + + [Fact] + public void GetAllSiteStates_ReturnsAllKnownSites() + { + _aggregator.ProcessReport(MakeReport("site-1", 1)); + _aggregator.ProcessReport(MakeReport("site-2", 1)); + + var states = _aggregator.GetAllSiteStates(); + Assert.Equal(2, states.Count); + Assert.Contains("site-1", states.Keys); + Assert.Contains("site-2", states.Keys); + } + + [Fact] + public void GetSiteState_ReturnsNull_ForUnknownSite() + { + var state = _aggregator.GetSiteState("nonexistent"); + Assert.Null(state); + } + + [Fact] + public void ProcessReport_StoresLatestReport() + { + var report = MakeReport("site-1", 1) with { ScriptErrorCount = 42 }; + _aggregator.ProcessReport(report); + + var state = _aggregator.GetSiteState("site-1"); + Assert.Equal(42, state!.LatestReport.ScriptErrorCount); + } + + [Fact] + public void SequenceNumberReset_RejectedUntilExceedsPrevMax() + { + // Site sends seq 10, then restarts and sends seq 1. + // Per design: sequence resets on singleton restart. + // The aggregator will reject seq 1 < 10 — expected behavior. + _aggregator.ProcessReport(MakeReport("site-1", 10)); + _aggregator.ProcessReport(MakeReport("site-1", 1)); + + var state = _aggregator.GetSiteState("site-1"); + Assert.Equal(10, state!.LastSequenceNumber); + + // Once it exceeds the old max, it works again + _aggregator.ProcessReport(MakeReport("site-1", 11)); + Assert.Equal(11, state.LastSequenceNumber); + } +} diff --git a/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs new file mode 100644 index 0000000..94b1fd2 --- /dev/null +++ b/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.HealthMonitoring.Tests; + +public class HealthReportSenderTests +{ + private class FakeTransport : IHealthReportTransport + { + public List SentReports { get; } = []; + public void Send(SiteHealthReport report) => SentReports.Add(report); + } + + private class FakeSiteIdentityProvider : ISiteIdentityProvider + { + public string SiteId { get; set; } = "test-site"; + } + + [Fact] + public async Task SendsReportsWithMonotonicSequenceNumbers() + { + var transport = new FakeTransport(); + var collector = new SiteHealthCollector(); + var options = Options.Create(new HealthMonitoringOptions + { + ReportInterval = TimeSpan.FromMilliseconds(50) + }); + + var sender = new HealthReportSender( + collector, + transport, + options, + NullLogger.Instance, + new FakeSiteIdentityProvider { SiteId = "site-A" }); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + try + { + await sender.StartAsync(cts.Token); + await Task.Delay(280, CancellationToken.None); + await sender.StopAsync(CancellationToken.None); + } + catch (OperationCanceledException) { } + + // Should have sent several reports + Assert.True(transport.SentReports.Count >= 2, + $"Expected at least 2 reports, got {transport.SentReports.Count}"); + + // Verify monotonic sequence numbers starting at 1 + for (int i = 0; i < transport.SentReports.Count; i++) + { + Assert.Equal(i + 1, transport.SentReports[i].SequenceNumber); + Assert.Equal("site-A", transport.SentReports[i].SiteId); + } + } + + [Fact] + public async Task SequenceNumberStartsAtOne() + { + var transport = new FakeTransport(); + var collector = new SiteHealthCollector(); + var options = Options.Create(new HealthMonitoringOptions + { + ReportInterval = TimeSpan.FromMilliseconds(50) + }); + + var sender = new HealthReportSender( + collector, + transport, + options, + NullLogger.Instance, + new FakeSiteIdentityProvider()); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + try + { + await sender.StartAsync(cts.Token); + await Task.Delay(120, CancellationToken.None); + await sender.StopAsync(CancellationToken.None); + } + catch (OperationCanceledException) { } + + Assert.True(transport.SentReports.Count >= 1); + Assert.Equal(1, transport.SentReports[0].SequenceNumber); + } + + [Fact] + public async Task ReportsIncludeUtcTimestamp() + { + var transport = new FakeTransport(); + var collector = new SiteHealthCollector(); + var options = Options.Create(new HealthMonitoringOptions + { + ReportInterval = TimeSpan.FromMilliseconds(50) + }); + + var sender = new HealthReportSender( + collector, + transport, + options, + NullLogger.Instance, + new FakeSiteIdentityProvider()); + + var before = DateTimeOffset.UtcNow; + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + try + { + await sender.StartAsync(cts.Token); + await Task.Delay(120, CancellationToken.None); + await sender.StopAsync(CancellationToken.None); + } + catch (OperationCanceledException) { } + var after = DateTimeOffset.UtcNow; + + Assert.True(transport.SentReports.Count >= 1); + foreach (var report in transport.SentReports) + { + Assert.InRange(report.ReportTimestamp, before, after); + Assert.Equal(TimeSpan.Zero, report.ReportTimestamp.Offset); + } + } + + [Fact] + public void InitialSequenceNumberIsZero() + { + var transport = new FakeTransport(); + var collector = new SiteHealthCollector(); + var options = Options.Create(new HealthMonitoringOptions()); + + var sender = new HealthReportSender( + collector, + transport, + options, + NullLogger.Instance, + new FakeSiteIdentityProvider()); + + Assert.Equal(0, sender.CurrentSequenceNumber); + } +} diff --git a/tests/ScadaLink.HealthMonitoring.Tests/ScadaLink.HealthMonitoring.Tests.csproj b/tests/ScadaLink.HealthMonitoring.Tests/ScadaLink.HealthMonitoring.Tests.csproj index 5936d45..3c67cbb 100644 --- a/tests/ScadaLink.HealthMonitoring.Tests/ScadaLink.HealthMonitoring.Tests.csproj +++ b/tests/ScadaLink.HealthMonitoring.Tests/ScadaLink.HealthMonitoring.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,6 +10,8 @@ + + @@ -23,4 +25,4 @@ - \ No newline at end of file + diff --git a/tests/ScadaLink.HealthMonitoring.Tests/SiteHealthCollectorTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/SiteHealthCollectorTests.cs new file mode 100644 index 0000000..8f3f0df --- /dev/null +++ b/tests/ScadaLink.HealthMonitoring.Tests/SiteHealthCollectorTests.cs @@ -0,0 +1,159 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.HealthMonitoring.Tests; + +public class SiteHealthCollectorTests +{ + private readonly SiteHealthCollector _collector = new(); + + [Fact] + public void CollectReport_ReturnsZeroCounters_WhenNoErrorsRecorded() + { + var report = _collector.CollectReport("site-1"); + + Assert.Equal("site-1", report.SiteId); + Assert.Equal(0, report.ScriptErrorCount); + Assert.Equal(0, report.AlarmEvaluationErrorCount); + Assert.Equal(0, report.DeadLetterCount); + } + + [Fact] + public void IncrementScriptError_AccumulatesBetweenReports() + { + _collector.IncrementScriptError(); + _collector.IncrementScriptError(); + _collector.IncrementScriptError(); + + var report = _collector.CollectReport("site-1"); + Assert.Equal(3, report.ScriptErrorCount); + } + + [Fact] + public void IncrementAlarmError_AccumulatesBetweenReports() + { + _collector.IncrementAlarmError(); + _collector.IncrementAlarmError(); + + var report = _collector.CollectReport("site-1"); + Assert.Equal(2, report.AlarmEvaluationErrorCount); + } + + [Fact] + public void IncrementDeadLetter_AccumulatesBetweenReports() + { + _collector.IncrementDeadLetter(); + + var report = _collector.CollectReport("site-1"); + Assert.Equal(1, report.DeadLetterCount); + } + + [Fact] + public void CollectReport_ResetsCounters_AfterCollection() + { + _collector.IncrementScriptError(); + _collector.IncrementAlarmError(); + _collector.IncrementDeadLetter(); + + var first = _collector.CollectReport("site-1"); + Assert.Equal(1, first.ScriptErrorCount); + Assert.Equal(1, first.AlarmEvaluationErrorCount); + Assert.Equal(1, first.DeadLetterCount); + + var second = _collector.CollectReport("site-1"); + Assert.Equal(0, second.ScriptErrorCount); + Assert.Equal(0, second.AlarmEvaluationErrorCount); + Assert.Equal(0, second.DeadLetterCount); + } + + [Fact] + public void UpdateConnectionHealth_ReflectedInReport() + { + _collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected); + _collector.UpdateConnectionHealth("opc-2", ConnectionHealth.Disconnected); + + var report = _collector.CollectReport("site-1"); + + Assert.Equal(2, report.DataConnectionStatuses.Count); + Assert.Equal(ConnectionHealth.Connected, report.DataConnectionStatuses["opc-1"]); + Assert.Equal(ConnectionHealth.Disconnected, report.DataConnectionStatuses["opc-2"]); + } + + [Fact] + public void ConnectionHealth_NotResetAfterCollect() + { + _collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected); + + _collector.CollectReport("site-1"); + var second = _collector.CollectReport("site-1"); + + Assert.Single(second.DataConnectionStatuses); + Assert.Equal(ConnectionHealth.Connected, second.DataConnectionStatuses["opc-1"]); + } + + [Fact] + public void RemoveConnection_RemovesFromReport() + { + _collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected); + _collector.UpdateTagResolution("opc-1", 10, 8); + _collector.RemoveConnection("opc-1"); + + var report = _collector.CollectReport("site-1"); + Assert.Empty(report.DataConnectionStatuses); + Assert.Empty(report.TagResolutionCounts); + } + + [Fact] + public void UpdateTagResolution_ReflectedInReport() + { + _collector.UpdateTagResolution("opc-1", 50, 45); + + var report = _collector.CollectReport("site-1"); + + Assert.Single(report.TagResolutionCounts); + Assert.Equal(50, report.TagResolutionCounts["opc-1"].TotalSubscribed); + Assert.Equal(45, report.TagResolutionCounts["opc-1"].SuccessfullyResolved); + } + + [Fact] + public void StoreAndForwardBufferDepths_IsEmptyPlaceholder() + { + var report = _collector.CollectReport("site-1"); + Assert.Empty(report.StoreAndForwardBufferDepths); + } + + [Fact] + public void CollectReport_IncludesUtcTimestamp() + { + var before = DateTimeOffset.UtcNow; + var report = _collector.CollectReport("site-1"); + var after = DateTimeOffset.UtcNow; + + Assert.InRange(report.ReportTimestamp, before, after); + } + + [Fact] + public void CollectReport_SequenceNumberIsZero_CallerAssignsIt() + { + var report = _collector.CollectReport("site-1"); + Assert.Equal(0, report.SequenceNumber); + } + + [Fact] + public async Task ThreadSafety_ConcurrentIncrements() + { + const int iterations = 10_000; + var tasks = new[] + { + Task.Run(() => { for (int i = 0; i < iterations; i++) _collector.IncrementScriptError(); }), + Task.Run(() => { for (int i = 0; i < iterations; i++) _collector.IncrementAlarmError(); }), + Task.Run(() => { for (int i = 0; i < iterations; i++) _collector.IncrementDeadLetter(); }) + }; + + await Task.WhenAll(tasks); + + var report = _collector.CollectReport("site-1"); + Assert.Equal(iterations, report.ScriptErrorCount); + Assert.Equal(iterations, report.AlarmEvaluationErrorCount); + Assert.Equal(iterations, report.DeadLetterCount); + } +} diff --git a/tests/ScadaLink.HealthMonitoring.Tests/UnitTest1.cs b/tests/ScadaLink.HealthMonitoring.Tests/UnitTest1.cs deleted file mode 100644 index 12119e6..0000000 --- a/tests/ScadaLink.HealthMonitoring.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ScadaLink.HealthMonitoring.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/ScadaLink.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs b/tests/ScadaLink.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs new file mode 100644 index 0000000..0059802 --- /dev/null +++ b/tests/ScadaLink.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace ScadaLink.SiteEventLogging.Tests; + +public class EventLogPurgeServiceTests : IDisposable +{ + private readonly SiteEventLogger _eventLogger; + private readonly string _dbPath; + private readonly SiteEventLogOptions _options; + + public EventLogPurgeServiceTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db"); + _options = new SiteEventLogOptions + { + DatabasePath = _dbPath, + RetentionDays = 30, + MaxStorageMb = 1024 + }; + _eventLogger = new SiteEventLogger( + Options.Create(_options), + NullLogger.Instance); + } + + public void Dispose() + { + _eventLogger.Dispose(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + private EventLogPurgeService CreatePurgeService(SiteEventLogOptions? optionsOverride = null) + { + var opts = optionsOverride ?? _options; + return new EventLogPurgeService( + _eventLogger, + Options.Create(opts), + NullLogger.Instance); + } + + private void InsertEventWithTimestamp(DateTimeOffset timestamp) + { + using var cmd = _eventLogger.Connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO site_events (timestamp, event_type, severity, source, message) + VALUES ($ts, 'script', 'Info', 'Test', 'Test message') + """; + cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o")); + cmd.ExecuteNonQuery(); + } + + private long GetEventCount() + { + using var cmd = _eventLogger.Connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM site_events"; + return (long)cmd.ExecuteScalar()!; + } + + [Fact] + public void PurgeByRetention_DeletesOldEvents() + { + // Insert an old event (31 days ago) and a recent one + InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31)); + InsertEventWithTimestamp(DateTimeOffset.UtcNow); + + var purge = CreatePurgeService(); + purge.RunPurge(); + + Assert.Equal(1, GetEventCount()); + } + + [Fact] + public void PurgeByRetention_KeepsRecentEvents() + { + InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-29)); + InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-1)); + InsertEventWithTimestamp(DateTimeOffset.UtcNow); + + var purge = CreatePurgeService(); + purge.RunPurge(); + + Assert.Equal(3, GetEventCount()); + } + + [Fact] + public void PurgeByStorageCap_DeletesOldestWhenOverCap() + { + // Insert enough events to have some data + for (int i = 0; i < 100; i++) + { + InsertEventWithTimestamp(DateTimeOffset.UtcNow); + } + + // Set an artificially small cap to trigger purge + var smallCapOptions = new SiteEventLogOptions + { + DatabasePath = _dbPath, + RetentionDays = 30, + MaxStorageMb = 0 // 0 MB cap forces purge + }; + + var purge = CreatePurgeService(smallCapOptions); + purge.RunPurge(); + + // All events should be purged since cap is 0 + Assert.Equal(0, GetEventCount()); + } + + [Fact] + public void GetDatabaseSizeBytes_ReturnsPositiveValue() + { + InsertEventWithTimestamp(DateTimeOffset.UtcNow); + + var purge = CreatePurgeService(); + var size = purge.GetDatabaseSizeBytes(); + + Assert.True(size > 0); + } +} diff --git a/tests/ScadaLink.SiteEventLogging.Tests/EventLogQueryServiceTests.cs b/tests/ScadaLink.SiteEventLogging.Tests/EventLogQueryServiceTests.cs new file mode 100644 index 0000000..2f6b55f --- /dev/null +++ b/tests/ScadaLink.SiteEventLogging.Tests/EventLogQueryServiceTests.cs @@ -0,0 +1,272 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Messages.RemoteQuery; + +namespace ScadaLink.SiteEventLogging.Tests; + +public class EventLogQueryServiceTests : IDisposable +{ + private readonly SiteEventLogger _eventLogger; + private readonly EventLogQueryService _queryService; + private readonly string _dbPath; + + public EventLogQueryServiceTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db"); + var options = Options.Create(new SiteEventLogOptions + { + DatabasePath = _dbPath, + QueryPageSize = 500 + }); + _eventLogger = new SiteEventLogger(options, NullLogger.Instance); + _queryService = new EventLogQueryService( + _eventLogger, + options, + NullLogger.Instance); + } + + public void Dispose() + { + _eventLogger.Dispose(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + private async Task SeedEvents() + { + await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script timeout"); + await _eventLogger.LogEventAsync("alarm", "Warning", "inst-1", "AlarmActor:TempHigh", "Alarm triggered"); + await _eventLogger.LogEventAsync("deployment", "Info", "inst-2", "DeploymentManager", "Instance deployed"); + await _eventLogger.LogEventAsync("connection", "Error", null, "DCL:OPC1", "Connection lost"); + await _eventLogger.LogEventAsync("script", "Info", "inst-2", "ScriptActor:Calculate", "Script completed"); + } + + private EventLogQueryRequest MakeRequest( + string? eventType = null, + string? severity = null, + string? instanceId = null, + string? keyword = null, + long? continuationToken = null, + int pageSize = 500, + DateTimeOffset? from = null, + DateTimeOffset? to = null) => + new( + CorrelationId: Guid.NewGuid().ToString(), + SiteId: "site-1", + From: from, + To: to, + EventType: eventType, + Severity: severity, + InstanceId: instanceId, + KeywordFilter: keyword, + ContinuationToken: continuationToken, + PageSize: pageSize, + Timestamp: DateTimeOffset.UtcNow); + + [Fact] + public async Task Query_ReturnsAllEvents_WhenNoFilters() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest()); + + Assert.True(response.Success); + Assert.Equal(5, response.Entries.Count); + Assert.False(response.HasMore); + } + + [Fact] + public async Task Query_FiltersByEventType() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(eventType: "script")); + + Assert.True(response.Success); + Assert.Equal(2, response.Entries.Count); + Assert.All(response.Entries, e => Assert.Equal("script", e.EventType)); + } + + [Fact] + public async Task Query_FiltersBySeverity() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(severity: "Error")); + + Assert.True(response.Success); + Assert.Equal(2, response.Entries.Count); + Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity)); + } + + [Fact] + public async Task Query_FiltersByInstanceId() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(instanceId: "inst-1")); + + Assert.True(response.Success); + Assert.Equal(2, response.Entries.Count); + Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId)); + } + + [Fact] + public async Task Query_KeywordSearch_MatchesMessage() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout")); + + Assert.True(response.Success); + Assert.Single(response.Entries); + Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Query_KeywordSearch_MatchesSource() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(keyword: "AlarmActor")); + + Assert.True(response.Success); + Assert.Single(response.Entries); + Assert.Contains("AlarmActor", response.Entries[0].Source); + } + + [Fact] + public async Task Query_CombinesMultipleFilters() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest( + eventType: "script", + severity: "Error", + instanceId: "inst-1")); + + Assert.True(response.Success); + Assert.Single(response.Entries); + Assert.Equal("Script timeout", response.Entries[0].Message); + } + + [Fact] + public async Task Query_Pagination_ReturnsCorrectPageSize() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(pageSize: 2)); + + Assert.True(response.Success); + Assert.Equal(2, response.Entries.Count); + Assert.True(response.HasMore); + Assert.NotNull(response.ContinuationToken); + } + + [Fact] + public async Task Query_Pagination_ContinuationTokenWorksCorrectly() + { + await SeedEvents(); + + // Get first page + var page1 = _queryService.ExecuteQuery(MakeRequest(pageSize: 2)); + Assert.Equal(2, page1.Entries.Count); + Assert.True(page1.HasMore); + + // Get second page using continuation token + var page2 = _queryService.ExecuteQuery(MakeRequest( + pageSize: 2, + continuationToken: page1.ContinuationToken)); + Assert.Equal(2, page2.Entries.Count); + Assert.True(page2.HasMore); + + // Get third page + var page3 = _queryService.ExecuteQuery(MakeRequest( + pageSize: 2, + continuationToken: page2.ContinuationToken)); + Assert.Single(page3.Entries); + Assert.False(page3.HasMore); + + // Verify no overlapping entries + var allIds = page1.Entries.Select(e => e.Id) + .Concat(page2.Entries.Select(e => e.Id)) + .Concat(page3.Entries.Select(e => e.Id)) + .ToList(); + Assert.Equal(5, allIds.Distinct().Count()); + } + + [Fact] + public async Task Query_FiltersByTimeRange() + { + // Insert events at controlled times + var now = DateTimeOffset.UtcNow; + + // Insert with a direct SQL to control timestamps + InsertEventAt(now.AddHours(-2), "script", "Info", null, "S1", "Old event"); + InsertEventAt(now.AddMinutes(-30), "script", "Info", null, "S2", "Recent event"); + InsertEventAt(now, "script", "Info", null, "S3", "Now event"); + + var response = _queryService.ExecuteQuery(MakeRequest( + from: now.AddHours(-1), + to: now.AddMinutes(1))); + + Assert.True(response.Success); + Assert.Equal(2, response.Entries.Count); + } + + [Fact] + public async Task Query_EmptyResult_WhenNoMatches() + { + await SeedEvents(); + + var response = _queryService.ExecuteQuery(MakeRequest(eventType: "nonexistent")); + + Assert.True(response.Success); + Assert.Empty(response.Entries); + Assert.False(response.HasMore); + Assert.Null(response.ContinuationToken); + } + + [Fact] + public void Query_ReturnsCorrelationId() + { + var request = MakeRequest(); + var response = _queryService.ExecuteQuery(request); + + Assert.Equal(request.CorrelationId, response.CorrelationId); + Assert.Equal("site-1", response.SiteId); + } + + [Fact] + public async Task Query_ReturnsAllEventLogEntryFields() + { + await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Run", "Failure", "{\"stack\":\"trace\"}"); + + var response = _queryService.ExecuteQuery(MakeRequest()); + Assert.Single(response.Entries); + + var entry = response.Entries[0]; + Assert.True(entry.Id > 0); + Assert.Equal("script", entry.EventType); + Assert.Equal("Error", entry.Severity); + Assert.Equal("inst-1", entry.InstanceId); + Assert.Equal("ScriptActor:Run", entry.Source); + Assert.Equal("Failure", entry.Message); + Assert.Equal("{\"stack\":\"trace\"}", entry.Details); + } + + private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message) + { + using var cmd = _eventLogger.Connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO site_events (timestamp, event_type, severity, instance_id, source, message) + VALUES ($ts, $et, $sev, $iid, $src, $msg) + """; + cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o")); + cmd.Parameters.AddWithValue("$et", eventType); + cmd.Parameters.AddWithValue("$sev", severity); + cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$src", source); + cmd.Parameters.AddWithValue("$msg", message); + cmd.ExecuteNonQuery(); + } +} diff --git a/tests/ScadaLink.SiteEventLogging.Tests/ScadaLink.SiteEventLogging.Tests.csproj b/tests/ScadaLink.SiteEventLogging.Tests/ScadaLink.SiteEventLogging.Tests.csproj index adddd54..6c5413d 100644 --- a/tests/ScadaLink.SiteEventLogging.Tests/ScadaLink.SiteEventLogging.Tests.csproj +++ b/tests/ScadaLink.SiteEventLogging.Tests/ScadaLink.SiteEventLogging.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,6 +10,9 @@ + + + @@ -23,4 +26,4 @@ - \ No newline at end of file + diff --git a/tests/ScadaLink.SiteEventLogging.Tests/SiteEventLoggerTests.cs b/tests/ScadaLink.SiteEventLogging.Tests/SiteEventLoggerTests.cs new file mode 100644 index 0000000..02fc7b1 --- /dev/null +++ b/tests/ScadaLink.SiteEventLogging.Tests/SiteEventLoggerTests.cs @@ -0,0 +1,143 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace ScadaLink.SiteEventLogging.Tests; + +public class SiteEventLoggerTests : IDisposable +{ + private readonly SiteEventLogger _logger; + private readonly SqliteConnection _verifyConnection; + private readonly string _dbPath; + + public SiteEventLoggerTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db"); + var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }); + _logger = new SiteEventLogger(options, NullLogger.Instance); + + // Separate connection for verification queries + _verifyConnection = new SqliteConnection($"Data Source={_dbPath}"); + _verifyConnection.Open(); + } + + public void Dispose() + { + _verifyConnection.Dispose(); + _logger.Dispose(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + [Fact] + public async Task LogEventAsync_InsertsRecord() + { + await _logger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script failed", "{\"stack\":\"...\"}"); + + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM site_events"; + var count = (long)cmd.ExecuteScalar()!; + Assert.Equal(1, count); + } + + [Fact] + public async Task LogEventAsync_StoresAllFields() + { + await _logger.LogEventAsync("alarm", "Warning", "inst-2", "AlarmActor:TempHigh", "Alarm triggered", "{\"value\":95}"); + + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT event_type, severity, instance_id, source, message, details FROM site_events LIMIT 1"; + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal("alarm", reader.GetString(0)); + Assert.Equal("Warning", reader.GetString(1)); + Assert.Equal("inst-2", reader.GetString(2)); + Assert.Equal("AlarmActor:TempHigh", reader.GetString(3)); + Assert.Equal("Alarm triggered", reader.GetString(4)); + Assert.Equal("{\"value\":95}", reader.GetString(5)); + } + + [Fact] + public async Task LogEventAsync_NullableFieldsAllowed() + { + await _logger.LogEventAsync("deployment", "Info", null, "DeploymentManager", "Deployed instance"); + + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT instance_id, details FROM site_events LIMIT 1"; + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read()); + Assert.True(reader.IsDBNull(0)); + Assert.True(reader.IsDBNull(1)); + } + + [Fact] + public async Task LogEventAsync_StoresIso8601UtcTimestamp() + { + await _logger.LogEventAsync("connection", "Info", null, "DCL", "Connected"); + + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT timestamp FROM site_events LIMIT 1"; + var ts = (string)cmd.ExecuteScalar()!; + var parsed = DateTimeOffset.Parse(ts); + Assert.Equal(TimeSpan.Zero, parsed.Offset); + } + + [Fact] + public async Task LogEventAsync_ThrowsOnEmptyEventType() + { + await Assert.ThrowsAsync(() => + _logger.LogEventAsync("", "Info", null, "Source", "Message")); + } + + [Fact] + public async Task LogEventAsync_ThrowsOnEmptySeverity() + { + await Assert.ThrowsAsync(() => + _logger.LogEventAsync("script", "", null, "Source", "Message")); + } + + [Fact] + public async Task LogEventAsync_ThrowsOnEmptySource() + { + await Assert.ThrowsAsync(() => + _logger.LogEventAsync("script", "Info", null, "", "Message")); + } + + [Fact] + public async Task LogEventAsync_ThrowsOnEmptyMessage() + { + await Assert.ThrowsAsync(() => + _logger.LogEventAsync("script", "Info", null, "Source", "")); + } + + [Fact] + public async Task LogEventAsync_MultipleEvents_AutoIncrementIds() + { + await _logger.LogEventAsync("script", "Info", null, "S1", "First"); + await _logger.LogEventAsync("script", "Info", null, "S2", "Second"); + await _logger.LogEventAsync("script", "Info", null, "S3", "Third"); + + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT id FROM site_events ORDER BY id"; + using var reader = cmd.ExecuteReader(); + var ids = new List(); + while (reader.Read()) ids.Add(reader.GetInt64(0)); + + Assert.Equal(3, ids.Count); + Assert.True(ids[0] < ids[1] && ids[1] < ids[2]); + } + + [Fact] + public async Task AllEventTypes_Accepted() + { + var types = new[] { "script", "alarm", "deployment", "connection", "store_and_forward", "instance_lifecycle" }; + foreach (var t in types) + { + await _logger.LogEventAsync(t, "Info", null, "Test", $"Event type: {t}"); + } + + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(DISTINCT event_type) FROM site_events"; + var count = (long)cmd.ExecuteScalar()!; + Assert.Equal(6, count); + } +} diff --git a/tests/ScadaLink.SiteEventLogging.Tests/UnitTest1.cs b/tests/ScadaLink.SiteEventLogging.Tests/UnitTest1.cs deleted file mode 100644 index 85195a3..0000000 --- a/tests/ScadaLink.SiteEventLogging.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ScadaLink.SiteEventLogging.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/AlarmActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/AlarmActorTests.cs new file mode 100644 index 0000000..bebf742 --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/AlarmActorTests.cs @@ -0,0 +1,260 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.SiteRuntime.Actors; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Actors; + +/// +/// WP-16: Alarm Actor tests — value match, range violation, rate of change. +/// WP-21: Alarm on-trigger call direction tests. +/// +public class AlarmActorTests : TestKit, IDisposable +{ + private readonly SharedScriptLibrary _sharedLibrary; + private readonly SiteRuntimeOptions _options; + private readonly ScriptCompilationService _compilationService; + + public AlarmActorTests() + { + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + _options = new SiteRuntimeOptions(); + } + + void IDisposable.Dispose() + { + Shutdown(); + } + + [Fact] + public void AlarmActor_ValueMatch_ActivatesOnMatch() + { + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "HighTemp", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // Send value that matches + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); + + // Instance Actor should receive AlarmStateChanged + var msg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(AlarmState.Active, msg.State); + Assert.Equal("HighTemp", msg.AlarmName); + } + + [Fact] + public void AlarmActor_ValueMatch_ClearsOnNonMatch() + { + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "HighTemp", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // Activate + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); + var activateMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(AlarmState.Active, activateMsg.State); + + // Clear + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow)); + var clearMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(AlarmState.Normal, clearMsg.State); + } + + [Fact] + public void AlarmActor_RangeViolation_ActivatesOutsideRange() + { + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "TempRange", + TriggerType = "RangeViolation", + TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}", + PriorityLevel = 2 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "TempRange", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // Value within range -- no alarm + alarm.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow)); + instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + + // Value outside range -- alarm activates + alarm.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow)); + var msg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(AlarmState.Active, msg.State); + } + + [Fact] + public void AlarmActor_RangeViolation_ClearsWhenBackInRange() + { + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "TempRange", + TriggerType = "RangeViolation", + TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}", + PriorityLevel = 2 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "TempRange", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // Activate + alarm.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow)); + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + + // Clear + alarm.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "75", "Good", DateTimeOffset.UtcNow)); + var clearMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(AlarmState.Normal, clearMsg.State); + } + + [Fact] + public void AlarmActor_IgnoresUnmonitoredAttributes() + { + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "TempAlarm", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // Send change for a different attribute + alarm.Tell(new AttributeValueChanged( + "Pump1", "Pressure", "Pressure", "100", "Good", DateTimeOffset.UtcNow)); + + instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive() + { + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "TempAlarm", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // First trigger + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow)); + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + + // Second trigger with same value -- should NOT re-trigger + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow)); + instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void AlarmActor_StartsNormal_OnRestart() + { + // Per design: on restart, alarm starts normal, re-evaluates from incoming values + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "RestartAlarm", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "RestartAlarm", "Pump1", instanceProbe.Ref, alarmConfig, + null, _sharedLibrary, _options, + NullLogger.Instance))); + + // A "Good" value should not trigger since alarm starts Normal + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow)); + instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void AlarmActor_NoClearScript_OnDeactivation() + { + // WP-16: On clear, NO script is executed. Only on activate. + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "ClearTest", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "ClearTest", "Pump1", instanceProbe.Ref, alarmConfig, + null, // no on-trigger script + _sharedLibrary, _options, + NullLogger.Instance))); + + // Activate + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow)); + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + + // Clear -- should send state change but no script execution + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow)); + var clearMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(AlarmState.Normal, clearMsg.State); + + // No additional messages (no script execution side effects) + instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index 6ed1ea4..09bf0e9 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -8,6 +8,7 @@ using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; @@ -19,6 +20,8 @@ namespace ScadaLink.SiteRuntime.Tests.Actors; public class DeploymentManagerActorTests : TestKit, IDisposable { private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly string _dbFile; public DeploymentManagerActorTests() @@ -28,6 +31,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); } void IDisposable.Dispose() @@ -36,6 +43,18 @@ public class DeploymentManagerActorTests : TestKit, IDisposable try { File.Delete(_dbFile); } catch { /* cleanup */ } } + private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null) + { + options ??= new SiteRuntimeOptions(); + return ActorOf(Props.Create(() => new DeploymentManagerActor( + _storage, + _compilationService, + _sharedScriptLibrary, + null, // no stream manager in tests + options, + NullLogger.Instance))); + } + private static string MakeConfigJson(string instanceName) { var config = new FlattenedConfiguration @@ -56,14 +75,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable await _storage.StoreDeployedConfigAsync("Pump1", MakeConfigJson("Pump1"), "d1", "h1", true); await _storage.StoreDeployedConfigAsync("Pump2", MakeConfigJson("Pump2"), "d2", "h2", true); - var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }; - var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( - _storage, options, NullLogger.Instance))); + var actor = CreateDeploymentManager( + new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }); // Allow time for async startup (load configs + create actors) await Task.Delay(2000); - // Verify by deploying — if actors already exist, we'd get a warning + // Verify by deploying -- if actors already exist, we'd get a warning // Instead, verify by checking we can send lifecycle commands actor.Tell(new DisableInstanceCommand("cmd-1", "Pump1", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -77,14 +95,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable await _storage.StoreDeployedConfigAsync("Active1", MakeConfigJson("Active1"), "d1", "h1", true); await _storage.StoreDeployedConfigAsync("Disabled1", MakeConfigJson("Disabled1"), "d2", "h2", false); - var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }; - var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( - _storage, options, NullLogger.Instance))); + var actor = CreateDeploymentManager( + new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }); await Task.Delay(2000); // The disabled instance should NOT have an actor running - // Try to disable it — it should succeed (no actor to stop, but SQLite update works) + // Try to disable it -- it should succeed (no actor to stop, but SQLite update works) actor.Tell(new DisableInstanceCommand("cmd-2", "Disabled1", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Success); @@ -101,9 +118,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable } // Use a small batch size to force multiple batches - var options = new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 }; - var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( - _storage, options, NullLogger.Instance))); + var actor = CreateDeploymentManager( + new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 }); // Wait for all batches to complete (3 batches with 50ms delay = ~150ms + processing) await Task.Delay(3000); @@ -120,9 +136,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable [Fact] public async Task DeploymentManager_Deploy_CreatesNewInstance() { - var options = new SiteRuntimeOptions(); - var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( - _storage, options, NullLogger.Instance))); + var actor = CreateDeploymentManager(); await Task.Delay(500); // Wait for empty startup @@ -137,9 +151,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable [Fact] public async Task DeploymentManager_Lifecycle_DisableEnableDelete() { - var options = new SiteRuntimeOptions(); - var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( - _storage, options, NullLogger.Instance))); + var actor = CreateDeploymentManager(); await Task.Delay(500); @@ -150,7 +162,6 @@ public class DeploymentManagerActorTests : TestKit, IDisposable ExpectMsg(TimeSpan.FromSeconds(5)); // Wait for the async deploy persistence (PipeTo) to complete - // The deploy handler replies immediately but persists asynchronously await Task.Delay(1000); // Disable @@ -179,15 +190,9 @@ public class DeploymentManagerActorTests : TestKit, IDisposable [Fact] public void DeploymentManager_SupervisionStrategy_ResumesOnException() { - // Verify the supervision strategy by creating the actor and checking - // that it uses OneForOneStrategy - var options = new SiteRuntimeOptions(); - var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( - _storage, options, NullLogger.Instance))); + var actor = CreateDeploymentManager(); - // The actor exists and is responsive — supervision is configured - // The actual Resume behavior is verified implicitly: if an Instance Actor - // throws during message handling, it resumes rather than restarting + // The actor exists and is responsive -- supervision is configured actor.Tell(new DeployInstanceCommand( "dep-sup", "SupervisedPump", "sha256:sup", MakeConfigJson("SupervisedPump"), "admin", DateTimeOffset.UtcNow)); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs new file mode 100644 index 0000000..10899fb --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs @@ -0,0 +1,226 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Messages.DebugView; +using ScadaLink.Commons.Messages.Instance; +using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.SiteRuntime.Actors; +using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; +using System.Text.Json; + +namespace ScadaLink.SiteRuntime.Tests.Actors; + +/// +/// Integration tests for InstanceActor with child Script/Alarm actors (WP-15, WP-16, WP-24, WP-25). +/// +public class InstanceActorIntegrationTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteRuntimeOptions _options; + private readonly string _dbFile; + + public InstanceActorIntegrationTests() + { + _dbFile = Path.Combine(Path.GetTempPath(), $"instance-int-test-{Guid.NewGuid():N}.db"); + _storage = new SiteStorageService( + $"Data Source={_dbFile}", + NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + _options = new SiteRuntimeOptions + { + MaxScriptCallDepth = 10, + ScriptExecutionTimeoutSeconds = 30 + }; + } + + void IDisposable.Dispose() + { + Shutdown(); + try { File.Delete(_dbFile); } catch { /* cleanup */ } + } + + private IActorRef CreateInstanceWithScripts( + string instanceName, + IReadOnlyList? scripts = null, + IReadOnlyList? alarms = null) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = instanceName, + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }, + new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" } + ], + Scripts = scripts ?? [], + Alarms = alarms ?? [] + }; + + return ActorOf(Props.Create(() => new InstanceActor( + instanceName, + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, // no stream manager + _options, + NullLogger.Instance))); + } + + [Fact] + public void InstanceActor_CreatesScriptActors_FromConfig() + { + var scripts = new[] + { + new ResolvedScript + { + CanonicalName = "GetValue", + Code = "42" + } + }; + + var actor = CreateInstanceWithScripts("Pump1", scripts); + + // Verify script actor is reachable via CallScript + actor.Tell(new ScriptCallRequest("GetValue", null, 0, "corr-1")); + var result = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(result.Success); + Assert.Equal(42, result.ReturnValue); + } + + [Fact] + public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError() + { + var actor = CreateInstanceWithScripts("Pump1"); + + actor.Tell(new ScriptCallRequest("NonExistent", null, 0, "corr-2")); + var result = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.False(result.Success); + Assert.Contains("not found", result.ErrorMessage); + } + + [Fact] + public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox() + { + // WP-24: Instance Actor processes messages sequentially. + // Verify that rapid attribute changes don't corrupt state. + var actor = CreateInstanceWithScripts("Pump1"); + + // Send many rapid set commands + for (int i = 0; i < 50; i++) + { + actor.Tell(new SetStaticAttributeCommand( + $"corr-{i}", "Pump1", "Temperature", $"{i}", DateTimeOffset.UtcNow)); + } + + // Wait for all to process + for (int i = 0; i < 50; i++) + { + ExpectMsg(TimeSpan.FromSeconds(10)); + } + + // The last value should be the final one + actor.Tell(new GetAttributeRequest( + "corr-final", "Pump1", "Temperature", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(response.Found); + Assert.Equal("49", response.Value?.ToString()); + } + + [Fact] + public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot() + { + var actor = CreateInstanceWithScripts("Pump1"); + + // Wait for initialization + Thread.Sleep(500); + + actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-1")); + var snapshot = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.Equal("Pump1", snapshot.InstanceUniqueName); + Assert.True(snapshot.AttributeValues.Count >= 2); // Temperature + Status + Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue); + } + + [Fact] + public void InstanceActor_WP25_DebugViewSubscriber_ReceivesChanges() + { + var actor = CreateInstanceWithScripts("Pump1"); + + // Subscribe to debug view + actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-2")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + // Now change an attribute + actor.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "200", "Good", DateTimeOffset.UtcNow)); + + // The subscriber should receive the change notification + var changed = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("Temperature", changed.AttributeName); + Assert.Equal("200", changed.Value?.ToString()); + } + + [Fact] + public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications() + { + var actor = CreateInstanceWithScripts("Pump1"); + + // Subscribe + actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-3")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + // Unsubscribe + actor.Tell(new UnsubscribeDebugViewRequest("Pump1", "debug-3")); + + // Change attribute + actor.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "300", "Good", DateTimeOffset.UtcNow)); + + // Should NOT receive change notification + ExpectNoMsg(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void InstanceActor_CreatesAlarmActors_FromConfig() + { + var alarms = new[] + { + new ResolvedAlarm + { + CanonicalName = "HighTemp", + TriggerType = "RangeViolation", + TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}", + PriorityLevel = 1 + } + }; + + var actor = CreateInstanceWithScripts("Pump1", alarms: alarms); + + // Subscribe to debug view to observe alarm state changes + actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-alarm")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + // Send value outside range to trigger alarm + actor.Tell(new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow)); + + // Should receive the attribute change first (from debug subscription) + ExpectMsg(TimeSpan.FromSeconds(5)); + + // Then the alarm state change (forwarded by Instance Actor) + var alarmMsg = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("HighTemp", alarmMsg.AlarmName); + Assert.Equal(Commons.Types.Enums.AlarmState.Active, alarmMsg.State); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs index 4b2ed4a..8d4511a 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; +using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; @@ -16,6 +17,9 @@ namespace ScadaLink.SiteRuntime.Tests.Actors; public class InstanceActorTests : TestKit, IDisposable { private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteRuntimeOptions _options; private readonly string _dbFile; public InstanceActorTests() @@ -25,6 +29,24 @@ public class InstanceActorTests : TestKit, IDisposable $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + _options = new SiteRuntimeOptions(); + } + + private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) + { + return ActorOf(Props.Create(() => new InstanceActor( + instanceName, + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, // no stream manager in tests + _options, + NullLogger.Instance))); } void IDisposable.Dispose() @@ -46,11 +68,7 @@ public class InstanceActorTests : TestKit, IDisposable ] }; - var actor = ActorOf(Props.Create(() => new InstanceActor( - "Pump1", - JsonSerializer.Serialize(config), - _storage, - NullLogger.Instance))); + var actor = CreateInstanceActor("Pump1", config); // Query for an attribute that exists actor.Tell(new GetAttributeRequest( @@ -71,11 +89,7 @@ public class InstanceActorTests : TestKit, IDisposable Attributes = [] }; - var actor = ActorOf(Props.Create(() => new InstanceActor( - "Pump1", - JsonSerializer.Serialize(config), - _storage, - NullLogger.Instance))); + var actor = CreateInstanceActor("Pump1", config); actor.Tell(new GetAttributeRequest( "corr-2", "Pump1", "NonExistent", DateTimeOffset.UtcNow)); @@ -97,13 +111,9 @@ public class InstanceActorTests : TestKit, IDisposable ] }; - var actor = ActorOf(Props.Create(() => new InstanceActor( - "Pump1", - JsonSerializer.Serialize(config), - _storage, - NullLogger.Instance))); + var actor = CreateInstanceActor("Pump1", config); - // Set a static attribute — response comes async via PipeTo + // Set a static attribute -- response comes async via PipeTo actor.Tell(new SetStaticAttributeCommand( "corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow)); @@ -131,11 +141,7 @@ public class InstanceActorTests : TestKit, IDisposable ] }; - var actor = ActorOf(Props.Create(() => new InstanceActor( - "PumpPersist1", - JsonSerializer.Serialize(config), - _storage, - NullLogger.Instance))); + var actor = CreateInstanceActor("PumpPersist1", config); actor.Tell(new SetStaticAttributeCommand( "corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow)); @@ -166,11 +172,7 @@ public class InstanceActorTests : TestKit, IDisposable ] }; - var actor = ActorOf(Props.Create(() => new InstanceActor( - "PumpOverride1", - JsonSerializer.Serialize(config), - _storage, - NullLogger.Instance))); + var actor = CreateInstanceActor("PumpOverride1", config); // Wait for the async override loading to complete (PipeTo) await Task.Delay(1000); @@ -200,7 +202,7 @@ public class InstanceActorTests : TestKit, IDisposable overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy"); Assert.Empty(overrides); - // Create actor with fresh config — should NOT have the override + // Create actor with fresh config -- should NOT have the override var config = new FlattenedConfiguration { InstanceUniqueName = "PumpRedeploy", @@ -210,11 +212,7 @@ public class InstanceActorTests : TestKit, IDisposable ] }; - var actor = ActorOf(Props.Create(() => new InstanceActor( - "PumpRedeploy", - JsonSerializer.Serialize(config), - _storage, - NullLogger.Instance))); + var actor = CreateInstanceActor("PumpRedeploy", config); await Task.Delay(1000); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs new file mode 100644 index 0000000..cf891cc --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs @@ -0,0 +1,240 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.SiteRuntime.Actors; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Actors; + +/// +/// WP-15: Script Actor and Script Execution Actor tests. +/// WP-20: Recursion limit tests. +/// WP-22: Tell vs Ask convention tests. +/// WP-32: Script error handling tests. +/// +public class ScriptActorTests : TestKit, IDisposable +{ + private readonly SharedScriptLibrary _sharedLibrary; + private readonly SiteRuntimeOptions _options; + private readonly ScriptCompilationService _compilationService; + + public ScriptActorTests() + { + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + _options = new SiteRuntimeOptions + { + MaxScriptCallDepth = 10, + ScriptExecutionTimeoutSeconds = 30 + }; + } + + void IDisposable.Dispose() + { + Shutdown(); + } + + private Script CompileScript(string code) + { + var scriptOptions = ScriptOptions.Default + .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) + .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); + + var script = CSharpScript.Create(code, scriptOptions, typeof(ScriptGlobals)); + script.Compile(); + return script; + } + + [Fact] + public void ScriptActor_CallScript_ReturnsResult() + { + var compiled = CompileScript("42"); + var scriptConfig = new ResolvedScript + { + CanonicalName = "GetAnswer", + Code = "42" + }; + + var instanceActor = CreateTestProbe(); + var scriptActor = ActorOf(Props.Create(() => new ScriptActor( + "GetAnswer", + "TestInstance", + instanceActor.Ref, + compiled, + scriptConfig, + _sharedLibrary, + _options, + NullLogger.Instance))); + + // Ask pattern (WP-22) for CallScript + scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1")); + + var result = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(result.Success, $"Script call failed: {result.ErrorMessage}"); + Assert.Equal(42, result.ReturnValue); + } + + [Fact] + public void ScriptActor_CallScript_WithParameters_Works() + { + var compiled = CompileScript("(int)Parameters[\"x\"] + (int)Parameters[\"y\"]"); + var scriptConfig = new ResolvedScript + { + CanonicalName = "Add", + Code = "(int)Parameters[\"x\"] + (int)Parameters[\"y\"]" + }; + + var instanceActor = CreateTestProbe(); + var scriptActor = ActorOf(Props.Create(() => new ScriptActor( + "Add", + "TestInstance", + instanceActor.Ref, + compiled, + scriptConfig, + _sharedLibrary, + _options, + NullLogger.Instance))); + + var parameters = new Dictionary { ["x"] = 3, ["y"] = 4 }; + scriptActor.Tell(new ScriptCallRequest("Add", parameters, 0, "corr-2")); + + var result = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(result.Success); + Assert.Equal(7, result.ReturnValue); + } + + [Fact] + public void ScriptActor_NullCompiledScript_ReturnsError() + { + var scriptConfig = new ResolvedScript + { + CanonicalName = "Broken", + Code = "" + }; + + var instanceActor = CreateTestProbe(); + var scriptActor = ActorOf(Props.Create(() => new ScriptActor( + "Broken", + "TestInstance", + instanceActor.Ref, + null, // no compiled script + scriptConfig, + _sharedLibrary, + _options, + NullLogger.Instance))); + + scriptActor.Tell(new ScriptCallRequest("Broken", null, 0, "corr-3")); + + var result = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.False(result.Success); + Assert.Contains("not compiled", result.ErrorMessage); + } + + [Fact] + public void ScriptActor_ValueChangeTrigger_SpawnsExecution() + { + var compiled = CompileScript("\"triggered\""); + var scriptConfig = new ResolvedScript + { + CanonicalName = "OnChange", + Code = "\"triggered\"", + TriggerType = "ValueChange", + TriggerConfiguration = "{\"attributeName\":\"Temperature\"}" + }; + + var instanceActor = CreateTestProbe(); + var scriptActor = ActorOf(Props.Create(() => new ScriptActor( + "OnChange", + "TestInstance", + instanceActor.Ref, + compiled, + scriptConfig, + _sharedLibrary, + _options, + NullLogger.Instance))); + + // Send an attribute change that matches the trigger + scriptActor.Tell(new AttributeValueChanged( + "TestInstance", "Temperature", "Temperature", "100.0", "Good", DateTimeOffset.UtcNow)); + + // The script should execute (we can't easily verify the output since it's fire-and-forget) + // But we can verify the actor doesn't crash + ExpectNoMsg(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon() + { + var compiled = CompileScript("\"ok\""); + var scriptConfig = new ResolvedScript + { + CanonicalName = "Throttled", + Code = "\"ok\"", + TriggerType = "ValueChange", + TriggerConfiguration = "{\"attributeName\":\"Temp\"}", + MinTimeBetweenRuns = TimeSpan.FromMinutes(10) // long minimum + }; + + var instanceActor = CreateTestProbe(); + var scriptActor = ActorOf(Props.Create(() => new ScriptActor( + "Throttled", + "TestInstance", + instanceActor.Ref, + compiled, + scriptConfig, + _sharedLibrary, + _options, + NullLogger.Instance))); + + // First trigger -- should execute + scriptActor.Tell(new AttributeValueChanged( + "TestInstance", "Temp", "Temp", "1", "Good", DateTimeOffset.UtcNow)); + + // Second trigger immediately -- should be skipped due to min time + scriptActor.Tell(new AttributeValueChanged( + "TestInstance", "Temp", "Temp", "2", "Good", DateTimeOffset.UtcNow)); + + // No crash expected + ExpectNoMsg(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ScriptActor_WP32_ScriptFailure_DoesNotDisable() + { + // Script that throws an exception + var compiled = CompileScript("throw new System.Exception(\"boom\")"); + var scriptConfig = new ResolvedScript + { + CanonicalName = "Failing", + Code = "throw new System.Exception(\"boom\")" + }; + + var instanceActor = CreateTestProbe(); + var scriptActor = ActorOf(Props.Create(() => new ScriptActor( + "Failing", + "TestInstance", + instanceActor.Ref, + compiled, + scriptConfig, + _sharedLibrary, + _options, + NullLogger.Instance))); + + // First call -- fails + scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-1")); + var result1 = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result1.Success); + + // Second call -- should still work (script not disabled after failure) + scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-2")); + var result2 = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.False(result2.Success); // Still fails, but the actor is still alive + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/NegativeTests.cs b/tests/ScadaLink.SiteRuntime.Tests/NegativeTests.cs index 4c29627..4ba5558 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/NegativeTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/NegativeTests.cs @@ -82,7 +82,8 @@ public class NegativeTests checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table'"; var tableCount = (long)(await checkCmd.ExecuteScalarAsync())!; - // Only 2 tables: deployed_configurations and static_attribute_overrides + // Only 2 tables in this manually-created schema (tests the constraint that + // no template editing tables exist in the manually-created subset) Assert.Equal(2, tableCount); } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs new file mode 100644 index 0000000..5a2d4bf --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.SiteRuntime.Persistence; + +namespace ScadaLink.SiteRuntime.Tests.Persistence; + +/// +/// WP-33: Local Artifact Storage tests — shared scripts, external systems, +/// database connections, notification lists. +/// +public class ArtifactStorageTests : IAsyncLifetime, IDisposable +{ + private readonly string _dbFile; + private SiteStorageService _storage = null!; + + public ArtifactStorageTests() + { + _dbFile = Path.Combine(Path.GetTempPath(), $"artifact-test-{Guid.NewGuid():N}.db"); + } + + public async Task InitializeAsync() + { + _storage = new SiteStorageService( + $"Data Source={_dbFile}", + NullLogger.Instance); + await _storage.InitializeAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + public void Dispose() + { + try { File.Delete(_dbFile); } catch { /* cleanup */ } + } + + // ── Shared Script Storage ── + + [Fact] + public async Task StoreSharedScript_RoundTrips() + { + await _storage.StoreSharedScriptAsync("CalcAvg", "return 42;", "{}", "int"); + + var scripts = await _storage.GetAllSharedScriptsAsync(); + Assert.Single(scripts); + Assert.Equal("CalcAvg", scripts[0].Name); + Assert.Equal("return 42;", scripts[0].Code); + Assert.Equal("{}", scripts[0].ParameterDefinitions); + Assert.Equal("int", scripts[0].ReturnDefinition); + } + + [Fact] + public async Task StoreSharedScript_Upserts_OnConflict() + { + await _storage.StoreSharedScriptAsync("CalcAvg", "return 1;", null, null); + await _storage.StoreSharedScriptAsync("CalcAvg", "return 2;", "{\"x\":\"int\"}", "int"); + + var scripts = await _storage.GetAllSharedScriptsAsync(); + Assert.Single(scripts); + Assert.Equal("return 2;", scripts[0].Code); + Assert.Equal("{\"x\":\"int\"}", scripts[0].ParameterDefinitions); + } + + [Fact] + public async Task StoreSharedScript_MultipleScripts() + { + await _storage.StoreSharedScriptAsync("Script1", "1", null, null); + await _storage.StoreSharedScriptAsync("Script2", "2", null, null); + await _storage.StoreSharedScriptAsync("Script3", "3", null, null); + + var scripts = await _storage.GetAllSharedScriptsAsync(); + Assert.Equal(3, scripts.Count); + } + + [Fact] + public async Task StoreSharedScript_NullableFields() + { + await _storage.StoreSharedScriptAsync("Simple", "42", null, null); + + var scripts = await _storage.GetAllSharedScriptsAsync(); + Assert.Single(scripts); + Assert.Null(scripts[0].ParameterDefinitions); + Assert.Null(scripts[0].ReturnDefinition); + } + + // ── External System Storage ── + + [Fact] + public async Task StoreExternalSystem_DoesNotThrow() + { + await _storage.StoreExternalSystemAsync( + "WeatherAPI", "https://api.weather.com", + "ApiKey", "{\"key\":\"abc\"}", "{\"getForecast\":{}}"); + + // No exception = success. Query verification would need a Get method. + } + + [Fact] + public async Task StoreExternalSystem_Upserts() + { + await _storage.StoreExternalSystemAsync("API1", "https://v1", "Basic", null, null); + await _storage.StoreExternalSystemAsync("API1", "https://v2", "ApiKey", "{}", null); + + // Upsert should not throw + } + + // ── Database Connection Storage ── + + [Fact] + public async Task StoreDatabaseConnection_DoesNotThrow() + { + await _storage.StoreDatabaseConnectionAsync( + "MainDB", "Server=localhost;Database=main", 3, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task StoreDatabaseConnection_Upserts() + { + await _storage.StoreDatabaseConnectionAsync( + "DB1", "Server=old", 3, TimeSpan.FromSeconds(1)); + await _storage.StoreDatabaseConnectionAsync( + "DB1", "Server=new", 5, TimeSpan.FromSeconds(2)); + + // Upsert should not throw + } + + // ── Notification List Storage ── + + [Fact] + public async Task StoreNotificationList_DoesNotThrow() + { + await _storage.StoreNotificationListAsync( + "Ops Team", ["ops@example.com", "admin@example.com"]); + } + + [Fact] + public async Task StoreNotificationList_Upserts() + { + await _storage.StoreNotificationListAsync("Team1", ["a@b.com"]); + await _storage.StoreNotificationListAsync("Team1", ["x@y.com", "z@w.com"]); + + // Upsert should not throw + } + + // ── Schema includes all WP-33 tables ── + + [Fact] + public async Task Initialize_CreatesAllArtifactTables() + { + // The initialize already ran. Verify by storing to each table. + await _storage.StoreSharedScriptAsync("s", "code", null, null); + await _storage.StoreExternalSystemAsync("e", "url", "None", null, null); + await _storage.StoreDatabaseConnectionAsync("d", "connstr", 1, TimeSpan.Zero); + await _storage.StoreNotificationListAsync("n", ["email@test.com"]); + + // All succeeded without exceptions = tables exist + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj b/tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj index ccb5b83..2f92078 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj +++ b/tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj @@ -9,9 +9,12 @@ + + + diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs new file mode 100644 index 0000000..b71175e --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Scripts; + +/// +/// WP-19: Script Trust Model tests — validates forbidden API detection and compilation. +/// +public class ScriptCompilationServiceTests +{ + private readonly ScriptCompilationService _service; + + public ScriptCompilationServiceTests() + { + _service = new ScriptCompilationService(NullLogger.Instance); + } + + [Fact] + public void Compile_ValidScript_Succeeds() + { + var result = _service.Compile("test", "1 + 1"); + Assert.True(result.IsSuccess); + Assert.NotNull(result.CompiledScript); + Assert.Empty(result.Errors); + } + + [Fact] + public void Compile_InvalidSyntax_ReturnsErrors() + { + var result = _service.Compile("bad", "this is not valid C# {{{"); + Assert.False(result.IsSuccess); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void ValidateTrustModel_SystemIO_Forbidden() + { + var violations = _service.ValidateTrustModel("System.IO.File.ReadAllText(\"test\")"); + Assert.NotEmpty(violations); + Assert.Contains(violations, v => v.Contains("System.IO")); + } + + [Fact] + public void ValidateTrustModel_Process_Forbidden() + { + var violations = _service.ValidateTrustModel( + "System.Diagnostics.Process.Start(\"cmd\")"); + Assert.NotEmpty(violations); + } + + [Fact] + public void ValidateTrustModel_Reflection_Forbidden() + { + var violations = _service.ValidateTrustModel( + "typeof(string).GetType().GetMethods(System.Reflection.BindingFlags.Public)"); + Assert.NotEmpty(violations); + } + + [Fact] + public void ValidateTrustModel_Sockets_Forbidden() + { + var violations = _service.ValidateTrustModel( + "new System.Net.Sockets.TcpClient()"); + Assert.NotEmpty(violations); + } + + [Fact] + public void ValidateTrustModel_HttpClient_Forbidden() + { + var violations = _service.ValidateTrustModel( + "new System.Net.Http.HttpClient()"); + Assert.NotEmpty(violations); + } + + [Fact] + public void ValidateTrustModel_AsyncAwait_Allowed() + { + // System.Threading.Tasks should be allowed (async/await support) + var violations = _service.ValidateTrustModel( + "await System.Threading.Tasks.Task.Delay(100)"); + Assert.Empty(violations); + } + + [Fact] + public void ValidateTrustModel_CancellationToken_Allowed() + { + var violations = _service.ValidateTrustModel( + "System.Threading.CancellationToken.None"); + Assert.Empty(violations); + } + + [Fact] + public void ValidateTrustModel_CleanCode_NoViolations() + { + var code = @" + var x = 1 + 2; + var list = new List { 1, 2, 3 }; + var sum = list.Sum(); + sum"; + var violations = _service.ValidateTrustModel(code); + Assert.Empty(violations); + } + + [Fact] + public void Compile_ForbiddenApi_FailsValidation() + { + var result = _service.Compile("evil", "System.IO.File.Delete(\"/tmp/test\")"); + Assert.False(result.IsSuccess); + Assert.NotEmpty(result.Errors); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs new file mode 100644 index 0000000..2bd709f --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Scripts; + +/// +/// WP-17: Shared Script Library tests — compile, register, execute inline. +/// +public class SharedScriptLibraryTests +{ + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _library; + + public SharedScriptLibraryTests() + { + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _library = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + [Fact] + public void CompileAndRegister_ValidScript_Succeeds() + { + var result = _library.CompileAndRegister("add", "1 + 2"); + Assert.True(result); + Assert.True(_library.Contains("add")); + } + + [Fact] + public void CompileAndRegister_InvalidScript_ReturnsFalse() + { + var result = _library.CompileAndRegister("bad", "this is not valid {{{"); + Assert.False(result); + Assert.False(_library.Contains("bad")); + } + + [Fact] + public void CompileAndRegister_ForbiddenApi_ReturnsFalse() + { + var result = _library.CompileAndRegister("evil", "System.IO.File.Delete(\"/tmp\")"); + Assert.False(result); + } + + [Fact] + public void CompileAndRegister_Replaces_ExistingScript() + { + _library.CompileAndRegister("calc", "1 + 1"); + _library.CompileAndRegister("calc", "2 + 2"); + + Assert.True(_library.Contains("calc")); + // Should have only one entry + Assert.Equal(1, _library.GetRegisteredScriptNames().Count(n => n == "calc")); + } + + [Fact] + public void Remove_RegisteredScript_ReturnsTrue() + { + _library.CompileAndRegister("temp", "42"); + Assert.True(_library.Remove("temp")); + Assert.False(_library.Contains("temp")); + } + + [Fact] + public void Remove_NonexistentScript_ReturnsFalse() + { + Assert.False(_library.Remove("nonexistent")); + } + + [Fact] + public void GetRegisteredScriptNames_ReturnsAllNames() + { + _library.CompileAndRegister("a", "1"); + _library.CompileAndRegister("b", "2"); + _library.CompileAndRegister("c", "3"); + + var names = _library.GetRegisteredScriptNames(); + Assert.Equal(3, names.Count); + Assert.Contains("a", names); + Assert.Contains("b", names); + Assert.Contains("c", names); + } + + [Fact] + public async Task ExecuteAsync_NonexistentScript_Throws() + { + await Assert.ThrowsAsync( + () => _library.ExecuteAsync("missing", null!)); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs new file mode 100644 index 0000000..f5687b9 --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs @@ -0,0 +1,118 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Messages.Streaming; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.SiteRuntime.Streaming; + +namespace ScadaLink.SiteRuntime.Tests.Streaming; + +/// +/// WP-23: Site-Wide Akka Stream tests. +/// WP-25: Debug View Backend tests (subscribe/unsubscribe). +/// +public class SiteStreamManagerTests : TestKit, IDisposable +{ + private readonly SiteStreamManager _streamManager; + + public SiteStreamManagerTests() + { + var options = new SiteRuntimeOptions { StreamBufferSize = 100 }; + _streamManager = new SiteStreamManager( + Sys, options, NullLogger.Instance); + _streamManager.Initialize(); + } + + void IDisposable.Dispose() + { + Shutdown(); + } + + [Fact] + public void Subscribe_CreatesSubscription() + { + var probe = CreateTestProbe(); + var id = _streamManager.Subscribe("Pump1", probe.Ref); + + Assert.NotNull(id); + Assert.Equal(1, _streamManager.SubscriptionCount); + } + + [Fact] + public void Unsubscribe_RemovesSubscription() + { + var probe = CreateTestProbe(); + var id = _streamManager.Subscribe("Pump1", probe.Ref); + + Assert.True(_streamManager.Unsubscribe(id)); + Assert.Equal(0, _streamManager.SubscriptionCount); + } + + [Fact] + public void Unsubscribe_InvalidId_ReturnsFalse() + { + Assert.False(_streamManager.Unsubscribe("nonexistent")); + } + + [Fact] + public void PublishAttributeValueChanged_ForwardsToSubscriber() + { + var probe = CreateTestProbe(); + _streamManager.Subscribe("Pump1", probe.Ref); + + var changed = new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow); + _streamManager.PublishAttributeValueChanged(changed); + + var received = probe.ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Equal("Pump1", received.InstanceUniqueName); + Assert.Equal("Temperature", received.AttributeName); + } + + [Fact] + public void PublishAlarmStateChanged_ForwardsToSubscriber() + { + var probe = CreateTestProbe(); + _streamManager.Subscribe("Pump1", probe.Ref); + + var changed = new AlarmStateChanged( + "Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow); + _streamManager.PublishAlarmStateChanged(changed); + + var received = probe.ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Equal("Pump1", received.InstanceUniqueName); + Assert.Equal(AlarmState.Active, received.State); + } + + [Fact] + public void PublishAttributeValueChanged_FiltersbyInstance() + { + var probe1 = CreateTestProbe(); + var probe2 = CreateTestProbe(); + _streamManager.Subscribe("Pump1", probe1.Ref); + _streamManager.Subscribe("Pump2", probe2.Ref); + + var changed = new AttributeValueChanged( + "Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow); + _streamManager.PublishAttributeValueChanged(changed); + + // Pump1 subscriber should receive + probe1.ExpectMsg(TimeSpan.FromSeconds(3)); + + // Pump2 subscriber should NOT receive + probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void RemoveSubscriber_RemovesAllSubscriptionsForActor() + { + var probe = CreateTestProbe(); + _streamManager.Subscribe("Pump1", probe.Ref); + _streamManager.Subscribe("Pump2", probe.Ref); + + Assert.Equal(2, _streamManager.SubscriptionCount); + + _streamManager.RemoveSubscriber(probe.Ref); + Assert.Equal(0, _streamManager.SubscriptionCount); + } +}