using Akka; using Akka.Actor; using Akka.Streams; using Akka.Streams.Dsl; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; /// /// Site-Wide Akka Stream — manages a broadcast stream for attribute value /// and alarm state changes. Instance Actors publish events via fire-and-forget Tell. /// A BroadcastHub fans events out to per-subscriber graphs, each filtered by /// instance name and bounded by a drop-oldest buffer. /// /// Filterable by instance name for debug view. /// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors /// to instance events without referencing SiteRuntime directly. /// public class SiteStreamManager : ISiteStreamSubscriber { private ActorSystem? _system; private IMaterializer? _materializer; private readonly int _bufferSize; private readonly ILogger _logger; private readonly object _lock = new(); private IActorRef? _sourceActor; private Source? _hubSource; private readonly Dictionary _subscriptions = new(); /// Initializes the stream manager with configuration and logger; the Akka stream is not started until is called. /// Site runtime options providing the stream buffer size. /// Logger instance. public SiteStreamManager( SiteRuntimeOptions options, ILogger logger) { _bufferSize = options.StreamBufferSize; _logger = logger; } /// /// Initializes the broadcast stream. Must be called after ActorSystem is ready. /// The ActorSystem is passed here rather than via the constructor so that /// SiteStreamManager can be created by DI before the actor system exists. /// /// The running Akka used to materialize the broadcast stream. public void Initialize(ActorSystem system) { _system = system; _materializer = _system.Materializer(); var (sourceActor, hubSource) = Source.ActorRef( _bufferSize, OverflowStrategy.DropHead) .ToMaterialized( BroadcastHub.Sink(bufferSize: 256), Keep.Both) .Run(_materializer); _sourceActor = sourceActor; _hubSource = hubSource; _logger.LogInformation( "SiteStreamManager initialized with publish buffer size {BufferSize}", _bufferSize); } /// /// Publishes an attribute value change to the broadcast hub. /// Fire-and-forget — never blocks the calling actor. /// /// The attribute value change event to publish. public void PublishAttributeValueChanged(AttributeValueChanged changed) { _sourceActor?.Tell(changed); } /// /// Publishes an alarm state change to the broadcast hub. /// Fire-and-forget — never blocks the calling actor. /// /// The alarm state change event to publish. public void PublishAlarmStateChanged(AlarmStateChanged changed) { _sourceActor?.Tell(changed); } /// public string Subscribe(string instanceName, IActorRef subscriber) { if (_hubSource is null || _materializer is null) throw new InvalidOperationException("SiteStreamManager.Initialize must be called before Subscribe"); var subscriptionId = Guid.NewGuid().ToString(); var capturedInstance = instanceName; var capturedSubscriber = subscriber; var killSwitch = _hubSource .Where(ev => ev.InstanceUniqueName == capturedInstance) .Buffer(_bufferSize, OverflowStrategy.DropHead) .ViaMaterialized(KillSwitches.Single(), Keep.Right) .To(Sink.ForEach(ev => capturedSubscriber.Tell(ev))) .Run(_materializer); lock (_lock) { _subscriptions[subscriptionId] = new SubscriptionInfo( instanceName, subscriber, killSwitch, DateTimeOffset.UtcNow); } _logger.LogDebug( "Subscriber {SubscriptionId} registered for instance {Instance}", subscriptionId, instanceName); return subscriptionId; } /// /// Unsubscribe from instance events. Shuts down the per-subscriber /// stream graph via its KillSwitch. /// /// The subscription ID returned by . /// true if the subscription was found and removed; false if it was already gone. public bool Unsubscribe(string subscriptionId) { SubscriptionInfo? info; lock (_lock) { if (!_subscriptions.Remove(subscriptionId, out info)) return false; } info.KillSwitch.Shutdown(); _logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId); return true; } /// public void RemoveSubscriber(IActorRef subscriber) { List toShutdown; lock (_lock) { var matched = _subscriptions .Where(kvp => kvp.Value.Subscriber.Equals(subscriber)) .ToList(); foreach (var kvp in matched) _subscriptions.Remove(kvp.Key); toShutdown = matched.Select(kvp => kvp.Value).ToList(); } foreach (var info in toShutdown) info.KillSwitch.Shutdown(); if (toShutdown.Count > 0) { _logger.LogDebug( "Removed {Count} subscriptions for disconnected subscriber", toShutdown.Count); } } /// /// Returns the count of active subscriptions (for diagnostics/testing). /// public int SubscriptionCount { get { lock (_lock) { return _subscriptions.Count; } } } private record SubscriptionInfo( string InstanceName, IActorRef Subscriber, IKillSwitch KillSwitch, DateTimeOffset SubscribedAt); }