Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging

Communication Layer (WP-1–5):
- 8 message patterns with correlation IDs, per-pattern timeouts
- Central/Site communication actors, transport heartbeat config
- Connection failure handling (no central buffering, debug streams killed)

Data Connection Layer (WP-6–14, WP-34):
- Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting)
- OPC UA + LmxProxy adapters behind IDataConnection
- Auto-reconnect, bad quality propagation, transparent re-subscribe
- Write-back, tag path resolution with retry, health reporting
- Protocol extensibility via DataConnectionFactory

Site Runtime (WP-15–25, WP-32–33):
- ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher)
- AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state)
- SharedScriptLibrary (inline execution), ScriptRuntimeContext (API)
- ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout)
- Recursion limit (default 10), call direction enforcement
- SiteStreamManager (per-subscriber bounded buffers, fire-and-forget)
- Debug view backend (snapshot + stream), concurrency serialization
- Local artifact storage (4 SQLite tables)

Health Monitoring (WP-26–28):
- SiteHealthCollector (thread-safe counters, connection state)
- HealthReportSender (30s interval, monotonic sequence numbers)
- CentralHealthAggregator (offline detection 60s, online recovery)

Site Event Logging (WP-29–31):
- SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC)
- EventLogPurgeService (30-day retention, 1GB cap)
- EventLogQueryService (filters, keyword search, keyset pagination)

541 tests pass, zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 20:57:25 -04:00
parent a3bf0c43f3
commit 389f5a0378
97 changed files with 8308 additions and 127 deletions

View File

@@ -0,0 +1,13 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Messages.DataConnection;
/// <summary>
/// Health metrics for a single data connection, contributed to the site health report.
/// </summary>
public record DataConnectionHealthReport(
string ConnectionName,
ConnectionHealth Status,
int TotalSubscribedTags,
int ResolvedTags,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,11 @@
namespace ScadaLink.Commons.Messages.DataConnection;
/// <summary>
/// Request from an Instance Actor to subscribe to tag values through the DCL.
/// </summary>
public record SubscribeTagsRequest(
string CorrelationId,
string InstanceUniqueName,
string ConnectionName,
IReadOnlyList<string> TagPaths,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,11 @@
namespace ScadaLink.Commons.Messages.DataConnection;
/// <summary>
/// Response confirming tag subscription registration.
/// </summary>
public record SubscribeTagsResponse(
string CorrelationId,
string InstanceUniqueName,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,21 @@
using ScadaLink.Commons.Interfaces.Protocol;
namespace ScadaLink.Commons.Messages.DataConnection;
/// <summary>
/// Published by DCL to an Instance Actor when a subscribed tag value changes.
/// </summary>
public record TagValueUpdate(
string ConnectionName,
string TagPath,
object? Value,
QualityCode Quality,
DateTimeOffset Timestamp);
/// <summary>
/// Published by DCL when connection state changes, causing bulk quality updates.
/// </summary>
public record ConnectionQualityChanged(
string ConnectionName,
QualityCode Quality,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,10 @@
namespace ScadaLink.Commons.Messages.DataConnection;
/// <summary>
/// Request from an Instance Actor to unsubscribe from all its tags when stopping.
/// </summary>
public record UnsubscribeTagsRequest(
string CorrelationId,
string InstanceUniqueName,
string ConnectionName,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,21 @@
namespace ScadaLink.Commons.Messages.DataConnection;
/// <summary>
/// Request to write a value to a device tag through the DCL.
/// Write failures are returned synchronously to the calling script.
/// </summary>
public record WriteTagRequest(
string CorrelationId,
string ConnectionName,
string TagPath,
object? Value,
DateTimeOffset Timestamp);
/// <summary>
/// Response for a device tag write operation.
/// </summary>
public record WriteTagResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,14 @@
namespace ScadaLink.Commons.Messages.Integration;
/// <summary>
/// Request routed from central to site to invoke an integration method
/// (external system call or notification) on behalf of the central UI or API.
/// </summary>
public record IntegrationCallRequest(
string CorrelationId,
string SiteId,
string InstanceUniqueName,
string TargetSystemName,
string MethodName,
IReadOnlyDictionary<string, object?> Parameters,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,12 @@
namespace ScadaLink.Commons.Messages.Integration;
/// <summary>
/// Response for an integration call routed through central-site communication.
/// </summary>
public record IntegrationCallResponse(
string CorrelationId,
string SiteId,
bool Success,
string? ResultJson,
string? ErrorMessage,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,19 @@
namespace ScadaLink.Commons.Messages.RemoteQuery;
/// <summary>
/// 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).
/// </summary>
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);

View File

@@ -0,0 +1,28 @@
namespace ScadaLink.Commons.Messages.RemoteQuery;
/// <summary>
/// A single event log entry returned from a site query.
/// </summary>
public record EventLogEntry(
long Id,
DateTimeOffset Timestamp,
string EventType,
string Severity,
string? InstanceId,
string Source,
string Message,
string? Details);
/// <summary>
/// Response containing paginated event log entries from a site.
/// Uses keyset pagination: ContinuationToken is the last event ID in the result set.
/// </summary>
public record EventLogQueryResponse(
string CorrelationId,
string SiteId,
IReadOnlyList<EventLogEntry> Entries,
long? ContinuationToken,
bool HasMore,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,11 @@
namespace ScadaLink.Commons.Messages.RemoteQuery;
/// <summary>
/// Request to query parked (permanently failed) store-and-forward messages at a site.
/// </summary>
public record ParkedMessageQueryRequest(
string CorrelationId,
string SiteId,
int PageNumber,
int PageSize,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,24 @@
namespace ScadaLink.Commons.Messages.RemoteQuery;
/// <summary>
/// Response containing parked store-and-forward messages from a site.
/// </summary>
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<ParkedMessageEntry> Messages,
int TotalCount,
int PageNumber,
int PageSize,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,172 @@
using Akka.Actor;
using Akka.Event;
using ScadaLink.Commons.Messages.Communication;
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.Communication.Actors;
/// <summary>
/// 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.
/// </summary>
public class CentralCommunicationActor : ReceiveActor
{
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>
/// Maps SiteId → remote SiteCommunicationActor selection.
/// Updated when heartbeats arrive or connection state changes.
/// </summary>
private readonly Dictionary<string, ActorSelection> _siteSelections = new();
/// <summary>
/// Tracks active debug view subscriptions: correlationId → (siteId, subscriber).
/// Used to kill debug streams on site disconnection (WP-5).
/// </summary>
private readonly Dictionary<string, (string SiteId, IActorRef Subscriber)> _debugSubscriptions = new();
/// <summary>
/// Tracks in-progress deployments: deploymentId → siteId.
/// On central failover, in-progress deployments are treated as failed (WP-5).
/// </summary>
private readonly Dictionary<string, string> _inProgressDeployments = new();
public CentralCommunicationActor()
{
// Site registration via heartbeats
Receive<HeartbeatMessage>(HandleHeartbeat);
// Connection state changes
Receive<ConnectionStateChanged>(HandleConnectionStateChanged);
// Site registration command (manual or from discovery)
Receive<RegisterSite>(HandleRegisterSite);
// Route enveloped messages to sites
Receive<SiteEnvelope>(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();
}
}
/// <summary>
/// Command to register a site's remote communication actor path.
/// </summary>
public record RegisterSite(string SiteId, string RemoteActorPath);
/// <summary>
/// Notification sent to debug view subscribers when the stream is terminated
/// due to site disconnection (WP-5).
/// </summary>
public record DebugStreamTerminated(string SiteId, string CorrelationId);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class SiteCommunicationActor : ReceiveActor, IWithTimers
{
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly string _siteId;
private readonly CommunicationOptions _options;
/// <summary>
/// Reference to the local Deployment Manager singleton proxy.
/// </summary>
private readonly IActorRef _deploymentManagerProxy;
/// <summary>
/// Optional reference to the central communication actor for sending heartbeats/health.
/// Set via RegisterCentral message.
/// </summary>
private ActorSelection? _centralSelection;
/// <summary>
/// Local actor references for routing specific message patterns.
/// Populated via registration messages.
/// </summary>
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<RegisterCentralPath>(HandleRegisterCentral);
Receive<RegisterLocalHandler>(HandleRegisterLocalHandler);
// Pattern 1: Instance Deployment — forward to Deployment Manager
Receive<DeployInstanceCommand>(msg =>
{
_log.Debug("Routing DeployInstanceCommand for {0} to DeploymentManager", msg.InstanceUniqueName);
_deploymentManagerProxy.Forward(msg);
});
// Pattern 2: Lifecycle — forward to Deployment Manager
Receive<DisableInstanceCommand>(msg => _deploymentManagerProxy.Forward(msg));
Receive<EnableInstanceCommand>(msg => _deploymentManagerProxy.Forward(msg));
Receive<DeleteInstanceCommand>(msg => _deploymentManagerProxy.Forward(msg));
// Pattern 3: Artifact Deployment — forward to artifact handler if registered
Receive<DeployArtifactsCommand>(msg =>
{
if (_artifactHandler != null)
_artifactHandler.Forward(msg);
else
{
_log.Warning("No artifact handler registered, replying with failure");
Sender.Tell(new ArtifactDeploymentResponse(
msg.DeploymentId, _siteId, false, "Artifact handler not available", DateTimeOffset.UtcNow));
}
});
// Pattern 4: Integration Routing — forward to integration handler
Receive<IntegrationCallRequest>(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<SubscribeDebugViewRequest>(msg => _deploymentManagerProxy.Forward(msg));
Receive<UnsubscribeDebugViewRequest>(msg => _deploymentManagerProxy.Forward(msg));
// Pattern 7: Remote Queries
Receive<EventLogQueryRequest>(msg =>
{
if (_eventLogHandler != null)
_eventLogHandler.Forward(msg);
else
{
Sender.Tell(new EventLogQueryResponse(
msg.CorrelationId, _siteId, [], null, false, false,
"Event log handler not available", DateTimeOffset.UtcNow));
}
});
Receive<ParkedMessageQueryRequest>(msg =>
{
if (_parkedMessageHandler != null)
_parkedMessageHandler.Forward(msg);
else
{
Sender.Tell(new ParkedMessageQueryResponse(
msg.CorrelationId, _siteId, [], 0, msg.PageNumber, msg.PageSize, false,
"Parked message handler not available", DateTimeOffset.UtcNow));
}
});
// Internal: send heartbeat tick
Receive<SendHeartbeat>(_ => SendHeartbeatToCentral());
// Internal: forward health report to central
Receive<SiteHealthReport>(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;
}
/// <summary>
/// Command to register the central communication actor path for outbound messages.
/// </summary>
public record RegisterCentralPath(string CentralActorPath);
/// <summary>
/// Command to register a local actor as a handler for a specific message pattern.
/// </summary>
public record RegisterLocalHandler(LocalHandlerType HandlerType, IActorRef Handler);
public enum LocalHandlerType
{
EventLog,
ParkedMessages,
Integration,
Artifacts
}

View File

@@ -1,10 +1,35 @@
namespace ScadaLink.Communication;
/// <summary>
/// Configuration options for central-site communication, including per-pattern
/// timeouts and transport heartbeat settings.
/// </summary>
public class CommunicationOptions
{
/// <summary>Timeout for deployment commands (typically longest due to apply logic).</summary>
public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>Timeout for lifecycle commands (disable, enable, delete).</summary>
public TimeSpan LifecycleTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Timeout for artifact deployment commands.</summary>
public TimeSpan ArtifactDeploymentTimeout { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>Timeout for remote query requests (event logs, parked messages).</summary>
public TimeSpan QueryTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Timeout for integration call routing.</summary>
public TimeSpan IntegrationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Timeout for debug view subscribe/unsubscribe handshake.</summary>
public TimeSpan DebugViewTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>Timeout for health report acknowledgement (fire-and-forget, but bounded).</summary>
public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>Akka.Remote transport heartbeat interval.</summary>
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Akka.Remote transport failure detection threshold.</summary>
public TimeSpan TransportFailureThreshold { get; set; } = TimeSpan.FromSeconds(15);
}

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
public class CommunicationService
{
private readonly CommunicationOptions _options;
private readonly ILogger<CommunicationService> _logger;
private IActorRef? _centralCommunicationActor;
public CommunicationService(
IOptions<CommunicationOptions> options,
ILogger<CommunicationService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Sets the central communication actor reference. Called during actor system startup.
/// </summary>
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<DeploymentStatusResponse> 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<DeploymentStatusResponse>(
envelope, _options.DeploymentTimeout, cancellationToken);
}
// ── Pattern 2: Lifecycle ──
public async Task<InstanceLifecycleResponse> DisableInstanceAsync(
string siteId, DisableInstanceCommand command, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return await GetActor().Ask<InstanceLifecycleResponse>(
envelope, _options.LifecycleTimeout, cancellationToken);
}
public async Task<InstanceLifecycleResponse> EnableInstanceAsync(
string siteId, EnableInstanceCommand command, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return await GetActor().Ask<InstanceLifecycleResponse>(
envelope, _options.LifecycleTimeout, cancellationToken);
}
public async Task<InstanceLifecycleResponse> DeleteInstanceAsync(
string siteId, DeleteInstanceCommand command, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return await GetActor().Ask<InstanceLifecycleResponse>(
envelope, _options.LifecycleTimeout, cancellationToken);
}
// ── Pattern 3: Artifact Deployment ──
public async Task<ArtifactDeploymentResponse> DeployArtifactsAsync(
string siteId, DeployArtifactsCommand command, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return await GetActor().Ask<ArtifactDeploymentResponse>(
envelope, _options.ArtifactDeploymentTimeout, cancellationToken);
}
// ── Pattern 4: Integration Routing ──
public async Task<IntegrationCallResponse> RouteIntegrationCallAsync(
string siteId, IntegrationCallRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
return await GetActor().Ask<IntegrationCallResponse>(
envelope, _options.IntegrationTimeout, cancellationToken);
}
// ── Pattern 5: Debug View ──
public async Task<DebugViewSnapshot> SubscribeDebugViewAsync(
string siteId, SubscribeDebugViewRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
return await GetActor().Ask<DebugViewSnapshot>(
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<EventLogQueryResponse> QueryEventLogsAsync(
string siteId, EventLogQueryRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
return await GetActor().Ask<EventLogQueryResponse>(
envelope, _options.QueryTimeout, cancellationToken);
}
public async Task<ParkedMessageQueryResponse> QueryParkedMessagesAsync(
string siteId, ParkedMessageQueryRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
return await GetActor().Ask<ParkedMessageQueryResponse>(
envelope, _options.QueryTimeout, cancellationToken);
}
// ── Pattern 8: Heartbeat (site→central, Tell) ──
// Heartbeats are received by central, not sent. No method needed here.
}
/// <summary>
/// Envelope that wraps any message with a target site ID for routing.
/// Used by CentralCommunicationActor to resolve the site actor path.
/// </summary>
public record SiteEnvelope(string SiteId, object Message);

View File

@@ -8,8 +8,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka" Version="1.5.62" />
<PackageReference Include="Akka.Remote" Version="1.5.62" />
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,13 +6,18 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCommunication(this IServiceCollection services)
{
// Phase 0: skeleton only
services.AddOptions<CommunicationOptions>()
.BindConfiguration("Communication");
services.AddSingleton<CommunicationService>();
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;
}
}

View File

@@ -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;
/// <summary>
/// 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&amp;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).
/// </summary>
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!;
/// <summary>
/// Active subscriptions: instanceUniqueName → set of tag paths.
/// </summary>
private readonly Dictionary<string, HashSet<string>> _subscriptionsByInstance = new();
/// <summary>
/// Subscription IDs returned by the adapter: tagPath → subscriptionId.
/// </summary>
private readonly Dictionary<string, string> _subscriptionIds = new();
/// <summary>
/// Tags whose path resolution failed and are awaiting retry.
/// </summary>
private readonly HashSet<string> _unresolvedTags = new();
/// <summary>
/// Subscribers: instanceUniqueName → IActorRef (the Instance Actor).
/// </summary>
private readonly Dictionary<string, IActorRef> _subscribers = new();
/// <summary>
/// Tracks total subscribed and resolved tags for health reporting.
/// </summary>
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<string, string>()).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<string>();
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;
}

View File

@@ -0,0 +1,142 @@
using Akka.Actor;
using Akka.Event;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Messages.DataConnection;
namespace ScadaLink.DataConnectionLayer.Actors;
/// <summary>
/// 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.
/// </summary>
public class DataConnectionManagerActor : ReceiveActor
{
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly IDataConnectionFactory _factory;
private readonly DataConnectionOptions _options;
private readonly Dictionary<string, IActorRef> _connectionActors = new();
public DataConnectionManagerActor(
IDataConnectionFactory factory,
DataConnectionOptions options)
{
_factory = factory;
_options = options;
Receive<CreateConnectionCommand>(HandleCreateConnection);
Receive<SubscribeTagsRequest>(HandleRoute);
Receive<UnsubscribeTagsRequest>(HandleRoute);
Receive<WriteTagRequest>(HandleRouteWrite);
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(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());
}
}
/// <summary>
/// OneForOneStrategy with Restart for connection actors — a failed connection
/// should restart and attempt reconnection.
/// </summary>
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;
}));
}
}
/// <summary>
/// Command to create a new data connection actor for a specific protocol.
/// </summary>
public record CreateConnectionCommand(
string ConnectionName,
string ProtocolType,
IDictionary<string, string> ConnectionDetails);
/// <summary>
/// Command to remove a data connection actor.
/// </summary>
public record RemoveConnectionCommand(string ConnectionName);
/// <summary>
/// Request for health reports from all active connections.
/// </summary>
public record GetAllHealthReports;

View File

@@ -0,0 +1,120 @@
namespace ScadaLink.DataConnectionLayer.Adapters;
/// <summary>
/// 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.
/// </summary>
public interface ILmxProxyClient : IAsyncDisposable
{
/// <summary>
/// Opens a session to the LmxProxy server. Returns a session ID.
/// </summary>
Task<string> OpenSessionAsync(string host, int port, CancellationToken cancellationToken = default);
/// <summary>
/// Closes the current session.
/// </summary>
Task CloseSessionAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a keep-alive to maintain the session.
/// </summary>
Task SendKeepAliveAsync(CancellationToken cancellationToken = default);
bool IsConnected { get; }
string? SessionId { get; }
/// <summary>
/// Subscribes to tag value changes via gRPC streaming. Returns a subscription handle.
/// </summary>
Task<string> SubscribeTagAsync(
string tagPath,
Action<string, object?, DateTime, bool> 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<bool> WriteTagAsync(string tagPath, object? value, CancellationToken cancellationToken = default);
}
/// <summary>
/// Factory for creating ILmxProxyClient instances.
/// </summary>
public interface ILmxProxyClientFactory
{
ILmxProxyClient Create();
}
/// <summary>
/// Default factory that creates stub LmxProxy clients.
/// In production, this would create real LmxProxy SDK client instances.
/// </summary>
public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory
{
public ILmxProxyClient Create() => new StubLmxProxyClient();
}
/// <summary>
/// Stub LmxProxy client for development/testing.
/// </summary>
internal class StubLmxProxyClient : ILmxProxyClient
{
public bool IsConnected { get; private set; }
public string? SessionId { get; private set; }
public Task<string> 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<string> SubscribeTagAsync(
string tagPath, Action<string, object?, DateTime, bool> 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<bool> WriteTagAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
{
return Task.FromResult(true);
}
public ValueTask DisposeAsync()
{
IsConnected = false;
SessionId = null;
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,94 @@
namespace ScadaLink.DataConnectionLayer.Adapters;
/// <summary>
/// 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).
/// </summary>
public interface IOpcUaClient : IAsyncDisposable
{
Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default);
Task DisconnectAsync(CancellationToken cancellationToken = default);
bool IsConnected { get; }
/// <summary>
/// Creates a monitored item subscription for a node. Returns a subscription handle.
/// </summary>
Task<string> CreateSubscriptionAsync(
string nodeId,
Action<string, object?, DateTime, uint> 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<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
}
/// <summary>
/// Factory for creating IOpcUaClient instances.
/// </summary>
public interface IOpcUaClientFactory
{
IOpcUaClient Create();
}
/// <summary>
/// Default factory that creates stub OPC UA clients.
/// In production, this would create real OPC UA SDK client instances.
/// </summary>
public class DefaultOpcUaClientFactory : IOpcUaClientFactory
{
public IOpcUaClient Create() => new StubOpcUaClient();
}
/// <summary>
/// Stub OPC UA client for development/testing. A real implementation would
/// wrap the OPC Foundation .NET Standard Library.
/// </summary>
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<string> CreateSubscriptionAsync(
string nodeId, Action<string, object?, DateTime, uint> 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<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
return Task.FromResult<uint>(0); // Good status
}
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,196 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.DataConnectionLayer.Adapters;
/// <summary>
/// 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)
/// </summary>
public class LmxProxyDataConnection : IDataConnection
{
private readonly ILmxProxyClientFactory _clientFactory;
private readonly ILogger<LmxProxyDataConnection> _logger;
private ILmxProxyClient? _client;
private string _host = "localhost";
private int _port = 5000;
private ConnectionHealth _status = ConnectionHealth.Disconnected;
private Timer? _keepAliveTimer;
private readonly Dictionary<string, string> _subscriptionHandles = new();
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
{
_clientFactory = clientFactory;
_logger = logger;
}
public ConnectionHealth Status => _status;
public async Task ConnectAsync(IDictionary<string, string> 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<string> 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<ReadResult> 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<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, ReadResult>();
foreach (var tagPath in tagPaths)
{
results[tagPath] = await ReadAsync(tagPath, cancellationToken);
}
return results;
}
public async Task<WriteResult> 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<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, WriteResult>();
foreach (var (tagPath, value) in values)
{
results[tagPath] = await WriteAsync(tagPath, value, cancellationToken);
}
return results;
}
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue,
string responsePath, object? responseValue, TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var allValues = new Dictionary<string, object?>(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);
}
}
}

View File

@@ -0,0 +1,183 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.DataConnectionLayer.Adapters;
/// <summary>
/// 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
/// </summary>
public class OpcUaDataConnection : IDataConnection
{
private readonly IOpcUaClientFactory _clientFactory;
private readonly ILogger<OpcUaDataConnection> _logger;
private IOpcUaClient? _client;
private string _endpointUrl = string.Empty;
private ConnectionHealth _status = ConnectionHealth.Disconnected;
/// <summary>
/// Maps subscription IDs to their tag paths for cleanup.
/// </summary>
private readonly Dictionary<string, string> _subscriptionHandles = new();
public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
{
_clientFactory = clientFactory;
_logger = logger;
}
public ConnectionHealth Status => _status;
public async Task ConnectAsync(IDictionary<string, string> 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<string> 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<ReadResult> 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<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, ReadResult>();
foreach (var tagPath in tagPaths)
{
results[tagPath] = await ReadAsync(tagPath, cancellationToken);
}
return results;
}
public async Task<WriteResult> 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<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, WriteResult>();
foreach (var (tagPath, value) in values)
{
results[tagPath] = await WriteAsync(tagPath, value, cancellationToken);
}
return results;
}
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue,
string responsePath, object? responseValue, TimeSpan timeout,
CancellationToken cancellationToken = default)
{
// Write all values including the flag
var allValues = new Dictionary<string, object?>(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.");
}
/// <summary>
/// Maps OPC UA StatusCode to QualityCode.
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
/// </summary>
private static QualityCode MapStatusCode(uint statusCode)
{
if (statusCode == 0) return QualityCode.Good;
if ((statusCode & 0x80000000) != 0) return QualityCode.Bad;
return QualityCode.Uncertain;
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.DataConnectionLayer;
/// <summary>
/// WP-34: Default factory that resolves protocol type strings to IDataConnection adapters.
/// Protocol extensibility: register new adapters via the constructor or AddAdapter method.
/// </summary>
public class DataConnectionFactory : IDataConnectionFactory
{
private readonly Dictionary<string, Func<IDictionary<string, string>, 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<OpcUaDataConnection>()));
RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection(
new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger<LmxProxyDataConnection>()));
}
/// <summary>
/// Registers a new protocol adapter factory. This is the extension point
/// for adding new protocols without modifying existing code.
/// </summary>
public void RegisterAdapter(string protocolType, Func<IDictionary<string, string>, IDataConnection> factory)
{
_factories[protocolType] = factory;
}
public IDataConnection Create(string protocolType, IDictionary<string, string> connectionDetails)
{
if (!_factories.TryGetValue(protocolType, out var factory))
throw new ArgumentException($"Unknown protocol type: {protocolType}. Registered protocols: {string.Join(", ", _factories.Keys)}");
return factory(connectionDetails);
}
}

View File

@@ -1,8 +1,19 @@
namespace ScadaLink.DataConnectionLayer;
/// <summary>
/// Configuration options for the Data Connection Layer.
/// </summary>
public class DataConnectionOptions
{
/// <summary>Fixed interval between reconnect attempts after disconnect.</summary>
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Interval for retrying failed tag path resolution.</summary>
public TimeSpan TagResolutionRetryInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>Timeout for synchronous write operations to devices.</summary>
public TimeSpan WriteTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>LmxProxy keep-alive interval for gRPC sessions.</summary>
public TimeSpan LmxProxyKeepAliveInterval { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -0,0 +1,18 @@
using ScadaLink.Commons.Interfaces.Protocol;
namespace ScadaLink.DataConnectionLayer;
/// <summary>
/// WP-34: Factory for creating IDataConnection adapters based on protocol type.
/// Adding a new protocol = implement IDataConnection + register with the factory.
/// </summary>
public interface IDataConnectionFactory
{
/// <summary>
/// Creates an IDataConnection adapter for the specified protocol type.
/// </summary>
/// <param name="protocolType">Protocol identifier (e.g., "OpcUa", "LmxProxy").</param>
/// <param name="connectionDetails">Protocol-specific connection parameters.</param>
/// <returns>A configured but not yet connected IDataConnection instance.</returns>
IDataConnection Create(string protocolType, IDictionary<string, string> connectionDetails);
}

View File

@@ -8,8 +8,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka" Version="1.5.62" />
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,13 +6,19 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services)
{
// Phase 0: skeleton only
services.AddOptions<DataConnectionOptions>()
.BindConfiguration("DataConnectionLayer");
// WP-34: Register the factory for protocol extensibility
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
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;
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregator
{
private readonly ConcurrentDictionary<string, SiteHealthState> _siteStates = new();
private readonly HealthMonitoringOptions _options;
private readonly ILogger<CentralHealthAggregator> _logger;
private readonly TimeProvider _timeProvider;
public CentralHealthAggregator(
IOptions<HealthMonitoringOptions> options,
ILogger<CentralHealthAggregator> logger,
TimeProvider? timeProvider = null)
{
_options = options.Value;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// 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.
/// </summary>
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;
});
}
/// <summary>
/// Get the current health state for all known sites.
/// </summary>
public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates()
{
return new Dictionary<string, SiteHealthState>(_siteStates);
}
/// <summary>
/// Get the current health state for a specific site, or null if unknown.
/// </summary>
public SiteHealthState? GetSiteState(string siteId)
{
_siteStates.TryGetValue(siteId, out var state);
return state;
}
/// <summary>
/// Background task that periodically checks for offline sites.
/// </summary>
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);
}
}
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Periodically collects a SiteHealthReport and sends it to central via Akka remoting.
/// Sequence numbers are monotonic, starting at 1, and reset on service restart.
/// </summary>
public class HealthReportSender : BackgroundService
{
private readonly ISiteHealthCollector _collector;
private readonly IHealthReportTransport _transport;
private readonly HealthMonitoringOptions _options;
private readonly ILogger<HealthReportSender> _logger;
private readonly string _siteId;
private long _sequenceNumber;
public HealthReportSender(
ISiteHealthCollector collector,
IHealthReportTransport transport,
IOptions<HealthMonitoringOptions> options,
ILogger<HealthReportSender> logger,
ISiteIdentityProvider siteIdentityProvider)
{
_collector = collector;
_transport = transport;
_options = options.Value;
_logger = logger;
_siteId = siteIdentityProvider.SiteId;
}
/// <summary>
/// Current sequence number (for testing).
/// </summary>
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
}
}
}
}

View File

@@ -0,0 +1,14 @@
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Interface for central-side health aggregation.
/// Consumed by Central UI to display site health dashboards.
/// </summary>
public interface ICentralHealthAggregator
{
void ProcessReport(SiteHealthReport report);
IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates();
SiteHealthState? GetSiteState(string siteId);
}

View File

@@ -0,0 +1,12 @@
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Abstraction for sending health reports to central.
/// In production, implemented via Akka remoting (Tell, fire-and-forget).
/// </summary>
public interface IHealthReportTransport
{
void Send(SiteHealthReport report);
}

View File

@@ -0,0 +1,19 @@
using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Interface for site-side health metric collection.
/// Consumed by Site Runtime actors to report errors, and by DCL to report connection health.
/// </summary>
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);
}

View File

@@ -0,0 +1,10 @@
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Provides the identity of the current site.
/// Implemented by the Host component to supply configuration-driven site ID.
/// </summary>
public interface ISiteIdentityProvider
{
string SiteId { get; }
}

View File

@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
</ItemGroup>
@@ -16,4 +18,8 @@
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.HealthMonitoring.Tests" />
</ItemGroup>
</Project>

View File

@@ -4,15 +4,30 @@ namespace ScadaLink.HealthMonitoring;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Register site-side health monitoring services.
/// </summary>
public static IServiceCollection AddHealthMonitoring(this IServiceCollection services)
{
// Phase 0: skeleton only
services.AddSingleton<ISiteHealthCollector, SiteHealthCollector>();
services.AddHostedService<HealthReportSender>();
return services;
}
/// <summary>
/// Register central-side health aggregation services.
/// </summary>
public static IServiceCollection AddCentralHealthAggregation(this IServiceCollection services)
{
services.AddSingleton<CentralHealthAggregator>();
services.AddSingleton<ICentralHealthAggregator>(sp => sp.GetRequiredService<CentralHealthAggregator>());
services.AddHostedService(sp => sp.GetRequiredService<CentralHealthAggregator>());
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;
}
}

View File

@@ -0,0 +1,101 @@
using System.Collections.Concurrent;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Collects health metrics from all site subsystems.
/// Thread-safe: counters use Interlocked operations, connection/tag data uses ConcurrentDictionary.
/// </summary>
public class SiteHealthCollector : ISiteHealthCollector
{
private int _scriptErrorCount;
private int _alarmErrorCount;
private int _deadLetterCount;
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
private readonly ConcurrentDictionary<string, TagResolutionStatus> _tagResolutionCounts = new();
/// <summary>
/// Increment the script error counter. Covers unhandled exceptions,
/// timeouts, and recursion limit violations.
/// </summary>
public void IncrementScriptError()
{
Interlocked.Increment(ref _scriptErrorCount);
}
/// <summary>
/// Increment the alarm evaluation error counter.
/// </summary>
public void IncrementAlarmError()
{
Interlocked.Increment(ref _alarmErrorCount);
}
/// <summary>
/// Increment the dead letter counter for this reporting interval.
/// </summary>
public void IncrementDeadLetter()
{
Interlocked.Increment(ref _deadLetterCount);
}
/// <summary>
/// Update the health status for a named data connection.
/// Called by DCL when connection state changes.
/// </summary>
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health)
{
_connectionStatuses[connectionName] = health;
}
/// <summary>
/// Remove a connection from tracking (e.g., on connection disposal).
/// </summary>
public void RemoveConnection(string connectionName)
{
_connectionStatuses.TryRemove(connectionName, out _);
_tagResolutionCounts.TryRemove(connectionName, out _);
}
/// <summary>
/// Update tag resolution counts for a named data connection.
/// Called by DCL after tag resolution attempts.
/// </summary>
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved)
{
_tagResolutionCounts[connectionName] = new TagResolutionStatus(totalSubscribed, successfullyResolved);
}
/// <summary>
/// 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).
/// </summary>
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<string, ConnectionHealth>(_connectionStatuses);
var tagResolution = new Dictionary<string, TagResolutionStatus>(_tagResolutionCounts);
// S&F buffer depth: placeholder (Phase 3C)
var sfBufferDepths = new Dictionary<string, int>();
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);
}
}

View File

@@ -0,0 +1,15 @@
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// In-memory state for a single site's health, stored by the central aggregator.
/// </summary>
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; }
}

View File

@@ -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.
/// </summary>
public class AkkaHostedService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly NodeOptions _nodeOptions;
private readonly ClusterOptions _clusterOptions;
private readonly CommunicationOptions _communicationOptions;
private readonly ILogger<AkkaHostedService> _logger;
private ActorSystem? _actorSystem;
@@ -28,11 +35,13 @@ public class AkkaHostedService : IHostedService
IServiceProvider serviceProvider,
IOptions<NodeOptions> nodeOptions,
IOptions<ClusterOptions> clusterOptions,
IOptions<CommunicationOptions> communicationOptions,
ILogger<AkkaHostedService> 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<ILoggerFactory>();
@@ -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 {{
}
/// <summary>
/// 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.
/// </summary>
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<CommunicationService>();
commService?.SetCommunicationActor(centralCommActor);
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
}
/// <summary>
/// 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.
/// </summary>
@@ -146,6 +188,9 @@ akka {{
{
var siteRole = $"site-{_nodeOptions.SiteId}";
var storage = _serviceProvider.GetRequiredService<SiteStorageService>();
var compilationService = _serviceProvider.GetRequiredService<ScriptCompilationService>();
var sharedScriptLibrary = _serviceProvider.GetRequiredService<SharedScriptLibrary>();
var streamManager = _serviceProvider.GetService<SiteStreamManager>();
var siteRuntimeOptionsValue = _serviceProvider.GetService<IOptions<SiteRuntimeOptions>>()?.Value
?? new SiteRuntimeOptions();
var dmLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
@@ -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);
}
}

View File

@@ -0,0 +1,120 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// 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.
/// </summary>
public class EventLogPurgeService : BackgroundService
{
private readonly SiteEventLogger _eventLogger;
private readonly SiteEventLogOptions _options;
private readonly ILogger<EventLogPurgeService> _logger;
public EventLogPurgeService(
ISiteEventLogger eventLogger,
IOptions<SiteEventLogOptions> options,
ILogger<EventLogPurgeService> 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;
}
}

View File

@@ -0,0 +1,146 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.RemoteQuery;
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// 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).
/// </summary>
public class EventLogQueryService : IEventLogQueryService
{
private readonly SiteEventLogger _eventLogger;
private readonly SiteEventLogOptions _options;
private readonly ILogger<EventLogQueryService> _logger;
public EventLogQueryService(
ISiteEventLogger eventLogger,
IOptions<SiteEventLogOptions> options,
ILogger<EventLogQueryService> 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<string>();
var parameters = new List<SqliteParameter>();
// 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<EventLogEntry>();
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);
}
}
}

View File

@@ -0,0 +1,12 @@
using ScadaLink.Commons.Messages.RemoteQuery;
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// Interface for querying site event logs.
/// Used by Communication Layer to process remote queries from central.
/// </summary>
public interface IEventLogQueryService
{
EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request);
}

View File

@@ -0,0 +1,24 @@
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// Interface for recording operational events to the local SQLite event log.
/// </summary>
public interface ISiteEventLogger
{
/// <summary>
/// Record an event asynchronously.
/// </summary>
/// <param name="eventType">Category: script, alarm, deployment, connection, store_and_forward, instance_lifecycle</param>
/// <param name="severity">Info, Warning, or Error</param>
/// <param name="instanceId">Optional instance ID associated with the event</param>
/// <param name="source">Source identifier, e.g., "ScriptActor:MonitorSpeed"</param>
/// <param name="message">Human-readable event description</param>
/// <param name="details">Optional JSON details (stack traces, compilation errors, etc.)</param>
Task LogEventAsync(
string eventType,
string severity,
string? instanceId,
string source,
string message,
string? details = null);
}

View File

@@ -8,7 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
</ItemGroup>
@@ -16,4 +19,8 @@
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.SiteEventLogging.Tests" />
</ItemGroup>
</Project>

View File

@@ -4,15 +4,20 @@ namespace ScadaLink.SiteEventLogging;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Register site event logging services (recording, purge, query).
/// </summary>
public static IServiceCollection AddSiteEventLogging(this IServiceCollection services)
{
// Phase 0: skeleton only
services.AddSingleton<ISiteEventLogger, SiteEventLogger>();
services.AddSingleton<IEventLogQueryService, EventLogQueryService>();
services.AddHostedService<EventLogPurgeService>();
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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,107 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// 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.
/// </summary>
public class SiteEventLogger : ISiteEventLogger, IDisposable
{
private readonly SqliteConnection _connection;
private readonly ILogger<SiteEventLogger> _logger;
private readonly object _writeLock = new();
private bool _disposed;
public SiteEventLogger(
IOptions<SiteEventLogOptions> options,
ILogger<SiteEventLogger> 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();
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<object?>? _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<object?>? 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<AlarmTriggerType>(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<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle alarm execution completion
Receive<AlarmExecutionCompleted>(_ =>
_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);
}
/// <summary>
/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception.
/// </summary>
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;
}));
}
/// <summary>
/// Evaluates alarm condition on attribute change. Alarm evaluation errors are logged,
/// actor continues (does not crash).
/// </summary>
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;
}
}
/// <summary>
/// Spawns an AlarmExecutionActor to run the on-trigger script.
/// </summary>
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);

View File

@@ -0,0 +1,96 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// 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.
/// </summary>
public class AlarmExecutionActor : ReceiveActor
{
public AlarmExecutionActor(
string alarmName,
string instanceName,
Script<object?> 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<object?> 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<string, object?>(),
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);
}
});
}
}

View File

@@ -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<DeploymentManagerActor> _logger;
private readonly Dictionary<string, IActorRef> _instanceActors = new();
@@ -28,10 +34,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
public DeploymentManagerActor(
SiteStorageService storage,
ScriptCompilationService compilationService,
SharedScriptLibrary sharedScriptLibrary,
SiteStreamManager? streamManager,
SiteRuntimeOptions options,
ILogger<DeploymentManagerActor> logger)
{
_storage = storage;
_compilationService = compilationService;
_sharedScriptLibrary = sharedScriptLibrary;
_streamManager = streamManager;
_options = options;
_logger = logger;
@@ -41,6 +53,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<EnableInstanceCommand>(HandleEnable);
Receive<DeleteInstanceCommand>(HandleDelete);
// WP-33: Handle system-wide artifact deployment
Receive<DeployArtifactsCommand>(HandleDeployArtifacts);
// Internal startup messages
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
Receive<StartNextBatch>(HandleStartNextBatch);
@@ -317,6 +332,74 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_logger.LogInformation("Instance {Instance} deleted", instanceName);
}
/// <summary>
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
/// </summary>
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);
}
/// <summary>
/// Creates a child Instance Actor with the given name and configuration JSON.
/// </summary>
@@ -333,6 +416,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
instanceName,
configJson,
_storage,
_compilationService,
_sharedScriptLibrary,
_streamManager,
_options,
loggerFactory.CreateLogger<InstanceActor>()));
var actorRef = Context.ActorOf(props, instanceName);

View File

@@ -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.
/// </summary>
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<string, object?> _attributes = new();
private readonly Dictionary<string, AlarmState> _alarmStates = new();
private readonly Dictionary<string, IActorRef> _scriptActors = new();
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private FlattenedConfiguration? _configuration;
// WP-25: Debug view subscribers
private readonly Dictionary<string, IActorRef> _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<GetAttributeRequest>(HandleGetAttribute);
// Handle static attribute writes
@@ -55,7 +85,6 @@ public class InstanceActor : ReceiveActor
Receive<DisableInstanceCommand>(_ =>
{
_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<ScriptCallRequest>(HandleScriptCallRequest);
// WP-22/23: Handle attribute value changes from DCL (Tell pattern)
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// WP-16: Handle alarm state changes from Alarm Actors (Tell pattern)
Receive<AlarmStateChanged>(HandleAlarmStateChanged);
// WP-25: Debug view subscribe/unsubscribe (Ask pattern for snapshot)
Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);
// Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded);
}
@@ -84,6 +126,26 @@ public class InstanceActor : ReceiveActor
return new LoadOverridesResult(t.Result, null);
return new LoadOverridesResult(new Dictionary<string, string>(), t.Exception?.GetBaseException().Message);
}).PipeTo(self);
// Create child Script Actors and Alarm Actors from configuration
CreateChildActors();
}
/// <summary>
/// Supervision: Resume for child coordinator actors (Script/Alarm Actors preserve state).
/// </summary>
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;
}));
}
/// <summary>
@@ -103,12 +165,24 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// Updates a static attribute in memory and persists the override to SQLite.
/// WP-24: State mutation serialized through this actor's mailbox.
/// </summary>
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);
}
/// <summary>
/// WP-15: Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern (WP-22).
/// </summary>
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}'."));
}
}
/// <summary>
/// WP-22/23: Handles attribute value changes from DCL or static writes.
/// Updates in-memory state, publishes to stream, and notifies children.
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// WP-24: State mutation serialized through this actor
_attributes[changed.AttributeName] = changed.Value;
PublishAndNotifyChildren(changed);
}
/// <summary>
/// WP-16: Handles alarm state changes from Alarm Actors.
/// Updates in-memory alarm state and publishes to stream.
/// </summary>
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);
}
}
/// <summary>
/// WP-25: Debug view subscribe — returns snapshot and begins streaming.
/// </summary>
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);
}
/// <summary>
/// WP-25: Debug view unsubscribe — removes subscription.
/// </summary>
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);
}
/// <summary>
/// Publishes attribute change to stream and notifies child Script/Alarm actors.
/// WP-22: Tell for attribute notifications (fire-and-forget, never blocks).
/// </summary>
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);
}
}
/// <summary>
/// Applies static overrides loaded from SQLite on top of default values.
/// </summary>
@@ -154,11 +360,105 @@ public class InstanceActor : ReceiveActor
result.Overrides.Count, _instanceUniqueName);
}
/// <summary>
/// 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).
/// </summary>
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<object?>? 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);
}
/// <summary>
/// Read-only access to current attribute count (for testing/diagnostics).
/// </summary>
public int AttributeCount => _attributes.Count;
/// <summary>
/// Read-only access to script actor count (for testing/diagnostics).
/// </summary>
public int ScriptActorCount => _scriptActors.Count;
/// <summary>
/// Read-only access to alarm actor count (for testing/diagnostics).
/// </summary>
public int AlarmActorCount => _alarmActors.Count;
/// <summary>
/// Internal message for async override loading result.
/// </summary>

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
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<object?>? _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<object?>? 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<ScriptCallRequest>(HandleScriptCallRequest);
// Handle attribute value changes for value-change and conditional triggers
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle interval tick
Receive<IntervalTick>(_ => TrySpawnExecution(null));
// Handle execution completion (for logging/metrics)
Receive<ScriptExecutionCompleted>(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);
}
/// <summary>
/// Supervision: Resume on exception — coordinator preserves state.
/// ScriptExecutionActors are stopped on unhandled exceptions.
/// </summary>
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;
}));
}
/// <summary>
/// Handles CallScript ask from ScriptRuntimeContext or Instance Actor.
/// Spawns a ScriptExecutionActor and forwards the sender for reply.
/// </summary>
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);
}
/// <summary>
/// Handles attribute value changes — triggers script if configured for value-change or conditional.
/// </summary>
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);
}
}
}
}
/// <summary>
/// Attempts to spawn a script execution, respecting MinTimeBetweenRuns.
/// </summary>
private void TrySpawnExecution(IReadOnlyDictionary<string, object?>? 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());
}
/// <summary>
/// Spawns a new ScriptExecutionActor child for this invocation.
/// Multiple concurrent executions are allowed.
/// </summary>
private void SpawnExecution(
IReadOnlyDictionary<string, object?>? 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;

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
public class ScriptExecutionActor : ReceiveActor
{
public ScriptExecutionActor(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? 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<object?> compiledScript,
IReadOnlyDictionary<string, object?>? 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<string, object?>(),
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);
}
});
}
}

View File

@@ -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 ──
/// <summary>
/// Stores or updates a shared script. Uses UPSERT semantics.
/// </summary>
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);
}
/// <summary>
/// Returns all stored shared scripts.
/// </summary>
public async Task<List<StoredSharedScript>> 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<StoredSharedScript>();
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 ──
/// <summary>
/// Stores or updates an external system definition.
/// </summary>
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 ──
/// <summary>
/// Stores or updates a database connection definition.
/// </summary>
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 ──
/// <summary>
/// Stores or updates a notification list.
/// </summary>
public async Task StoreNotificationListAsync(string name, IReadOnlyList<string> 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();
}
}
/// <summary>
@@ -255,3 +430,14 @@ public class DeployedInstance
public bool IsEnabled { get; init; }
public string DeployedAt { get; init; } = string.Empty;
}
/// <summary>
/// Represents a shared script stored locally in SQLite (WP-33).
/// </summary>
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; }
}

View File

@@ -11,6 +11,8 @@
<PackageReference Include="Akka" Version="1.5.62" />
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="Akka.Streams" Version="1.5.62" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class ScriptCompilationService
{
private readonly ILogger<ScriptCompilationService> _logger;
/// <summary>
/// Namespaces that are forbidden in user scripts for security.
/// </summary>
private static readonly string[] ForbiddenNamespaces =
[
"System.IO",
"System.Diagnostics.Process",
"System.Threading",
"System.Reflection",
"System.Net.Sockets",
"System.Net.Http"
];
/// <summary>
/// Specific types/members allowed even within forbidden namespaces.
/// async/await is OK despite System.Threading being blocked.
/// </summary>
private static readonly string[] AllowedExceptions =
[
"System.Threading.Tasks",
"System.Threading.CancellationToken",
"System.Threading.CancellationTokenSource"
];
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
{
_logger = logger;
}
/// <summary>
/// Validates that the script source code does not reference forbidden APIs.
/// Returns a list of violation messages, empty if clean.
/// </summary>
public IReadOnlyList<string> ValidateTrustModel(string code)
{
var violations = new List<string>();
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;
}
/// <summary>
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
/// and parameters dictionary, and returns an object? result.
/// </summary>
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<object?>(
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}"]);
}
}
}
/// <summary>
/// Result of script compilation, containing either the compiled script or error messages.
/// </summary>
public class ScriptCompilationResult
{
public bool IsSuccess { get; }
public Script<object?>? CompiledScript { get; }
public IReadOnlyList<string> Errors { get; }
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
{
IsSuccess = success;
CompiledScript = script;
Errors = errors;
}
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
new(true, script, []);
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
new(false, null, errors);
}
/// <summary>
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
/// as the "Instance" global, and parameters are available via "Parameters".
/// </summary>
public class ScriptGlobals
{
public ScriptRuntimeContext Instance { get; set; } = null!;
public IReadOnlyDictionary<string, object?> Parameters { get; set; } =
new Dictionary<string, object?>();
public CancellationToken CancellationToken { get; set; }
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Gets the current value of an attribute from the Instance Actor.
/// Uses Ask pattern (system boundary between script execution and instance state).
/// </summary>
public async Task<object?> GetAttribute(string attributeName)
{
var correlationId = Guid.NewGuid().ToString();
var request = new GetAttributeRequest(
correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);
if (!response.Found)
{
_logger.LogWarning(
"GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'",
attributeName, _instanceName);
}
return response.Value;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Calls a sibling script on the same instance by name (Ask pattern).
/// WP-20: Enforces recursion limit.
/// WP-22: Uses Ask pattern for CallScript.
/// </summary>
public async Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? 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<ScriptCallResult>(request, _askTimeout);
if (!result.Success)
{
throw new InvalidOperationException(
$"CallScript('{scriptName}') failed: {result.ErrorMessage}");
}
return result.ReturnValue;
}
/// <summary>
/// Provides access to shared script execution via the Scripts property.
/// </summary>
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
/// <summary>
/// Helper class for Scripts.CallShared() syntax.
/// </summary>
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;
}
/// <summary>
/// WP-17: Executes a shared script inline (direct method call, not actor message).
/// WP-20: Enforces recursion limit.
/// </summary>
public async Task<object?> CallShared(
string scriptName,
IReadOnlyDictionary<string, object?>? 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);
}
}
}

View File

@@ -0,0 +1,114 @@
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
namespace ScadaLink.SiteRuntime.Scripts;
/// <summary>
/// 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.
/// </summary>
public class SharedScriptLibrary
{
private readonly ScriptCompilationService _compilationService;
private readonly ILogger<SharedScriptLibrary> _logger;
private readonly Dictionary<string, Script<object?>> _compiledScripts = new();
private readonly object _lock = new();
public SharedScriptLibrary(
ScriptCompilationService compilationService,
ILogger<SharedScriptLibrary> logger)
{
_compilationService = compilationService;
_logger = logger;
}
/// <summary>
/// Compiles and registers a shared script. Replaces any existing script with the same name.
/// Returns true if compilation succeeded, false otherwise.
/// </summary>
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;
}
/// <summary>
/// Removes a shared script from the library.
/// </summary>
public bool Remove(string name)
{
lock (_lock)
{
return _compiledScripts.Remove(name);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<object?> ExecuteAsync(
string scriptName,
ScriptRuntimeContext context,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
Script<object?> 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<string, object?>(),
CancellationToken = cancellationToken
};
var state = await script.RunAsync(globals, cancellationToken);
return state.ReturnValue;
}
/// <summary>
/// Returns the names of all currently registered shared scripts.
/// </summary>
public IReadOnlyList<string> GetRegisteredScriptNames()
{
lock (_lock)
{
return _compiledScripts.Keys.ToList();
}
}
/// <summary>
/// Returns whether a script with the given name is registered.
/// </summary>
public bool Contains(string name)
{
lock (_lock)
{
return _compiledScripts.ContainsKey(name);
}
}
}

View File

@@ -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<SiteStorageInitializer>();
// WP-19: Script compilation service
services.AddSingleton<ScriptCompilationService>();
// WP-17: Shared script library
services.AddSingleton<SharedScriptLibrary>();
return services;
}

View File

@@ -17,4 +17,23 @@ public class SiteRuntimeOptions
/// Default: 100ms.
/// </summary>
public int StartupBatchDelayMs { get; set; } = 100;
/// <summary>
/// Maximum call depth for recursive script calls (CallScript/CallShared).
/// Default: 10.
/// </summary>
public int MaxScriptCallDepth { get; set; } = 10;
/// <summary>
/// Default script execution timeout in seconds.
/// Default: 30 seconds.
/// </summary>
public int ScriptExecutionTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Per-subscriber buffer size for the site-wide Akka stream.
/// Slow subscribers drop oldest messages when buffer is full.
/// Default: 1000.
/// </summary>
public int StreamBufferSize { get; set; } = 1000;
}

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
public class SiteStreamManager
{
private readonly ActorSystem _system;
private readonly int _bufferSize;
private readonly ILogger<SiteStreamManager> _logger;
private readonly object _lock = new();
private IActorRef? _sourceActor;
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
public SiteStreamManager(
ActorSystem system,
SiteRuntimeOptions options,
ILogger<SiteStreamManager> logger)
{
_system = system;
_bufferSize = options.StreamBufferSize;
_logger = logger;
}
/// <summary>
/// Initializes the stream source. Must be called after ActorSystem is ready.
/// </summary>
public void Initialize()
{
var materializer = _system.Materializer();
var source = Source.ActorRef<ISiteStreamEvent>(
_bufferSize,
OverflowStrategy.DropHead);
var (actorRef, _) = source
.PreMaterialize(materializer);
_sourceActor = actorRef;
_logger.LogInformation(
"SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize);
}
/// <summary>
/// Publishes an attribute value change to the stream.
/// Fire-and-forget — never blocks the calling actor.
/// </summary>
public void PublishAttributeValueChanged(AttributeValueChanged changed)
{
_sourceActor?.Tell(changed);
// Also forward to filtered subscribers
ForwardToSubscribers(changed.InstanceUniqueName, changed);
}
/// <summary>
/// Publishes an alarm state change to the stream.
/// Fire-and-forget — never blocks the calling actor.
/// </summary>
public void PublishAlarmStateChanged(AlarmStateChanged changed)
{
_sourceActor?.Tell(changed);
// Also forward to filtered subscribers
ForwardToSubscribers(changed.InstanceUniqueName, changed);
}
/// <summary>
/// WP-25: Subscribe to events for a specific instance (debug view).
/// Returns a subscription ID for unsubscribing.
/// </summary>
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;
}
/// <summary>
/// WP-25: Unsubscribe from instance events.
/// </summary>
public bool Unsubscribe(string subscriptionId)
{
lock (_lock)
{
var removed = _subscriptions.Remove(subscriptionId);
if (removed)
{
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
}
return removed;
}
}
/// <summary>
/// WP-25: Remove all subscriptions for a specific subscriber actor.
/// Called when connection is interrupted.
/// </summary>
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);
}
}
}
/// <summary>
/// Returns the count of active subscriptions (for diagnostics/testing).
/// </summary>
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);
}
/// <summary>
/// Marker interface for events published to the site stream.
/// </summary>
public interface ISiteStreamEvent { }