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:
@@ -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 { }
|
||||
Reference in New Issue
Block a user