feat: replace debug view polling with real-time SignalR streaming
The debug view polled every 2s by re-subscribing for full snapshots. Now a persistent DebugStreamBridgeActor on central subscribes once and receives incremental Akka stream events from the site, forwarding them to the Blazor component via callbacks and to the CLI via a new SignalR hub at /hubs/debug-stream. Adds `debug stream` CLI command with auto-reconnect.
This commit is contained in:
100
src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs
Normal file
100
src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
|
||||
namespace ScadaLink.Communication.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent actor (one per active debug session) on the central side.
|
||||
/// Sends SubscribeDebugViewRequest to the site via CentralCommunicationActor (with THIS actor
|
||||
/// as the Sender), so the site's InstanceActor registers this actor as the debug subscriber.
|
||||
/// Stream events flow back via Akka remoting and are forwarded to the consumer via callbacks.
|
||||
/// </summary>
|
||||
public class DebugStreamBridgeActor : ReceiveActor
|
||||
{
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly string _siteIdentifier;
|
||||
private readonly string _instanceUniqueName;
|
||||
private readonly string _correlationId;
|
||||
private readonly IActorRef _centralCommunicationActor;
|
||||
private readonly Action<object> _onEvent;
|
||||
private readonly Action _onTerminated;
|
||||
|
||||
public DebugStreamBridgeActor(
|
||||
string siteIdentifier,
|
||||
string instanceUniqueName,
|
||||
string correlationId,
|
||||
IActorRef centralCommunicationActor,
|
||||
Action<object> onEvent,
|
||||
Action onTerminated)
|
||||
{
|
||||
_siteIdentifier = siteIdentifier;
|
||||
_instanceUniqueName = instanceUniqueName;
|
||||
_correlationId = correlationId;
|
||||
_centralCommunicationActor = centralCommunicationActor;
|
||||
_onEvent = onEvent;
|
||||
_onTerminated = onTerminated;
|
||||
|
||||
// Initial snapshot response from the site
|
||||
Receive<DebugViewSnapshot>(snapshot =>
|
||||
{
|
||||
_log.Info("Received initial snapshot for {0} ({1} attrs, {2} alarms)",
|
||||
_instanceUniqueName, snapshot.AttributeValues.Count, snapshot.AlarmStates.Count);
|
||||
_onEvent(snapshot);
|
||||
});
|
||||
|
||||
// Ongoing stream events from the site's InstanceActor
|
||||
Receive<AttributeValueChanged>(changed => _onEvent(changed));
|
||||
Receive<AlarmStateChanged>(changed => _onEvent(changed));
|
||||
|
||||
// Consumer requests stop
|
||||
Receive<StopDebugStream>(_ =>
|
||||
{
|
||||
_log.Info("Stopping debug stream for {0}", _instanceUniqueName);
|
||||
SendUnsubscribe();
|
||||
Context.Stop(Self);
|
||||
});
|
||||
|
||||
// Site disconnected — CentralCommunicationActor notifies us
|
||||
Receive<DebugStreamTerminated>(msg =>
|
||||
{
|
||||
_log.Warning("Debug stream terminated for {0} (site {1} disconnected)", _instanceUniqueName, msg.SiteId);
|
||||
_onTerminated();
|
||||
Context.Stop(Self);
|
||||
});
|
||||
|
||||
// Orphan safety net — if nobody stops us within 5 minutes, self-terminate
|
||||
Context.SetReceiveTimeout(TimeSpan.FromMinutes(5));
|
||||
Receive<ReceiveTimeout>(_ =>
|
||||
{
|
||||
_log.Warning("Debug stream for {0} timed out (orphaned session), stopping", _instanceUniqueName);
|
||||
SendUnsubscribe();
|
||||
_onTerminated();
|
||||
Context.Stop(Self);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void PreStart()
|
||||
{
|
||||
_log.Info("Starting debug stream bridge for {0} on site {1}", _instanceUniqueName, _siteIdentifier);
|
||||
|
||||
// Send subscribe request via CentralCommunicationActor.
|
||||
// THIS actor is the Sender, so the site's InstanceActor registers us as the subscriber.
|
||||
var request = new SubscribeDebugViewRequest(_instanceUniqueName, _correlationId);
|
||||
var envelope = new SiteEnvelope(_siteIdentifier, request);
|
||||
_centralCommunicationActor.Tell(envelope, Self);
|
||||
}
|
||||
|
||||
private void SendUnsubscribe()
|
||||
{
|
||||
var request = new UnsubscribeDebugViewRequest(_instanceUniqueName, _correlationId);
|
||||
var envelope = new SiteEnvelope(_siteIdentifier, request);
|
||||
_centralCommunicationActor.Tell(envelope, Self);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message sent to a DebugStreamBridgeActor to stop the debug stream session.
|
||||
/// </summary>
|
||||
public record StopDebugStream;
|
||||
@@ -48,12 +48,17 @@ public class CommunicationService
|
||||
GetActor().Tell(new RefreshSiteAddresses());
|
||||
}
|
||||
|
||||
private IActorRef GetActor()
|
||||
/// <summary>
|
||||
/// Gets the central communication actor reference. Throws if not yet initialized.
|
||||
/// </summary>
|
||||
public IActorRef GetCommunicationActor()
|
||||
{
|
||||
return _centralCommunicationActor
|
||||
?? throw new InvalidOperationException("CommunicationService not initialized. CentralCommunicationActor not set.");
|
||||
}
|
||||
|
||||
private IActorRef GetActor() => GetCommunicationActor();
|
||||
|
||||
// ── Pattern 1: Instance Deployment ──
|
||||
|
||||
public async Task<DeploymentStatusResponse> DeployInstanceAsync(
|
||||
|
||||
146
src/ScadaLink.Communication/DebugStreamService.cs
Normal file
146
src/ScadaLink.Communication/DebugStreamService.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Communication.Actors;
|
||||
|
||||
namespace ScadaLink.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Manages debug stream sessions by creating DebugStreamBridgeActors that persist
|
||||
/// as subscribers on the site side. Both the Blazor debug view and the SignalR hub
|
||||
/// use this service to start/stop streams.
|
||||
/// </summary>
|
||||
public class DebugStreamService
|
||||
{
|
||||
private readonly CommunicationService _communicationService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<DebugStreamService> _logger;
|
||||
private readonly ConcurrentDictionary<string, IActorRef> _sessions = new();
|
||||
private ActorSystem? _actorSystem;
|
||||
|
||||
public DebugStreamService(
|
||||
CommunicationService communicationService,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<DebugStreamService> logger)
|
||||
{
|
||||
_communicationService = communicationService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the ActorSystem reference. Called during actor system startup (from AkkaHostedService).
|
||||
/// </summary>
|
||||
public void SetActorSystem(ActorSystem actorSystem)
|
||||
{
|
||||
_actorSystem = actorSystem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a debug stream session. Returns the initial snapshot.
|
||||
/// Ongoing events are delivered via the onEvent callback.
|
||||
/// The onTerminated callback fires if the stream is killed (site disconnect, timeout).
|
||||
/// </summary>
|
||||
public async Task<DebugStreamSession> StartStreamAsync(
|
||||
int instanceId,
|
||||
Action<object> onEvent,
|
||||
Action onTerminated,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var system = _actorSystem
|
||||
?? throw new InvalidOperationException("DebugStreamService not initialized. ActorSystem not set.");
|
||||
|
||||
// Resolve instance → unique name + site
|
||||
string instanceUniqueName;
|
||||
string siteIdentifier;
|
||||
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var instanceRepo = scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>();
|
||||
var instance = await instanceRepo.GetInstanceByIdAsync(instanceId)
|
||||
?? throw new InvalidOperationException($"Instance {instanceId} not found.");
|
||||
|
||||
var siteRepo = scope.ServiceProvider.GetRequiredService<ISiteRepository>();
|
||||
var site = await siteRepo.GetSiteByIdAsync(instance.SiteId)
|
||||
?? throw new InvalidOperationException($"Site {instance.SiteId} not found.");
|
||||
|
||||
instanceUniqueName = instance.UniqueName;
|
||||
siteIdentifier = site.SiteIdentifier;
|
||||
}
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Capture the initial snapshot via a TaskCompletionSource
|
||||
var snapshotTcs = new TaskCompletionSource<DebugViewSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Action<object> onEventWrapper = evt =>
|
||||
{
|
||||
if (evt is DebugViewSnapshot snapshot && !snapshotTcs.Task.IsCompleted)
|
||||
{
|
||||
snapshotTcs.TrySetResult(snapshot);
|
||||
}
|
||||
else
|
||||
{
|
||||
onEvent(evt);
|
||||
}
|
||||
};
|
||||
|
||||
Action onTerminatedWrapper = () =>
|
||||
{
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
snapshotTcs.TrySetException(new InvalidOperationException("Debug stream terminated before snapshot received."));
|
||||
onTerminated();
|
||||
};
|
||||
|
||||
// Create the bridge actor — use type-based Props to avoid expression tree limitations with closures
|
||||
var commActor = _communicationService.GetCommunicationActor();
|
||||
|
||||
var props = Props.Create(typeof(DebugStreamBridgeActor),
|
||||
siteIdentifier,
|
||||
instanceUniqueName,
|
||||
sessionId,
|
||||
commActor,
|
||||
onEventWrapper,
|
||||
onTerminatedWrapper);
|
||||
|
||||
var bridgeActor = system.ActorOf(props, $"debug-stream-{sessionId}");
|
||||
|
||||
_sessions[sessionId] = bridgeActor;
|
||||
|
||||
// Wait for the initial snapshot (with timeout)
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshotTcs.Task.WaitAsync(timeoutCts.Token);
|
||||
|
||||
_logger.LogInformation("Debug stream {SessionId} started for {Instance} on site {Site}",
|
||||
sessionId, instanceUniqueName, siteIdentifier);
|
||||
|
||||
return new DebugStreamSession(sessionId, snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StopStream(sessionId);
|
||||
throw new TimeoutException($"Timed out waiting for debug snapshot from {instanceUniqueName} on site {siteIdentifier}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops an active debug stream session.
|
||||
/// </summary>
|
||||
public void StopStream(string sessionId)
|
||||
{
|
||||
if (_sessions.TryRemove(sessionId, out var bridgeActor))
|
||||
{
|
||||
bridgeActor.Tell(new StopDebugStream());
|
||||
_logger.LogInformation("Debug stream {SessionId} stopped", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record DebugStreamSession(string SessionId, DebugViewSnapshot InitialSnapshot);
|
||||
@@ -10,6 +10,7 @@ public static class ServiceCollectionExtensions
|
||||
.BindConfiguration("Communication");
|
||||
|
||||
services.AddSingleton<CommunicationService>();
|
||||
services.AddSingleton<DebugStreamService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user