fix: route debug stream events through ClusterClient site→central path

ClusterClient Sender refs are temporary proxies — valid for immediate reply
but not durable for future Tells. Events now flow as DebugStreamEvent through
SiteCommunicationActor → ClusterClient → CentralCommunicationActor → bridge
actor (same pattern as health reports). Also fix DebugStreamHub to use
IHubContext for long-lived callbacks instead of transient hub instance.
This commit is contained in:
Joseph Doherty
2026-03-21 11:32:17 -04:00
parent 41aff339b2
commit 3efec91386
7 changed files with 76 additions and 21 deletions

View File

@@ -0,0 +1,8 @@
namespace ScadaLink.Commons.Messages.DebugView;
/// <summary>
/// Wraps a debug stream event (AttributeValueChanged or AlarmStateChanged) with
/// the correlationId for routing back to the correct DebugStreamBridgeActor on central.
/// Sent from InstanceActor → SiteCommunicationActor → ClusterClient → CentralCommunicationActor.
/// </summary>
public record DebugStreamEvent(string CorrelationId, object Event);

View File

@@ -85,6 +85,9 @@ public class CentralCommunicationActor : ReceiveActor
// Route enveloped messages to sites
Receive<SiteEnvelope>(HandleSiteEnvelope);
// Route debug stream events from sites to the correct bridge actor
Receive<Commons.Messages.DebugView.DebugStreamEvent>(HandleDebugStreamEvent);
}
private void HandleHeartbeat(HeartbeatMessage heartbeat)
@@ -93,6 +96,18 @@ public class CentralCommunicationActor : ReceiveActor
Context.Parent.Tell(heartbeat);
}
private void HandleDebugStreamEvent(Commons.Messages.DebugView.DebugStreamEvent msg)
{
if (_debugSubscriptions.TryGetValue(msg.CorrelationId, out var entry))
{
entry.Subscriber.Tell(msg.Event);
}
else
{
_log.Debug("No debug subscription found for correlationId {0}, dropping event", msg.CorrelationId);
}
}
private void HandleSiteHealthReport(SiteHealthReport report)
{
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();

View File

@@ -145,6 +145,13 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
_centralClient?.Tell(
new ClusterClient.Send("/user/central-communication", msg), Self);
});
// Internal: forward debug stream events to central (site→central streaming)
Receive<DebugStreamEvent>(msg =>
{
_centralClient?.Tell(
new ClusterClient.Send("/user/central-communication", msg), Self);
});
}
protected override void PreStart()

View File

@@ -19,11 +19,16 @@ public class DebugStreamHub : Hub
private const string SessionIdKey = "DebugStreamSessionId";
private readonly DebugStreamService _debugStreamService;
private readonly IHubContext<DebugStreamHub> _hubContext;
private readonly ILogger<DebugStreamHub> _logger;
public DebugStreamHub(DebugStreamService debugStreamService, ILogger<DebugStreamHub> logger)
public DebugStreamHub(
DebugStreamService debugStreamService,
IHubContext<DebugStreamHub> hubContext,
ILogger<DebugStreamHub> logger)
{
_debugStreamService = debugStreamService;
_hubContext = hubContext;
_logger = logger;
}
@@ -105,6 +110,10 @@ public class DebugStreamHub : Hub
try
{
// Use IHubContext for callbacks — the hub instance is transient (disposed after method returns),
// but IHubContext is a singleton that remains valid for the lifetime of the connection.
var hubClients = _hubContext.Clients;
var session = await _debugStreamService.StartStreamAsync(
instanceId,
onEvent: evt =>
@@ -113,17 +122,17 @@ public class DebugStreamHub : Hub
_ = evt switch
{
AttributeValueChanged changed =>
Clients.Client(connectionId).SendAsync("OnAttributeChanged", changed),
hubClients.Client(connectionId).SendAsync("OnAttributeChanged", changed),
AlarmStateChanged changed =>
Clients.Client(connectionId).SendAsync("OnAlarmChanged", changed),
hubClients.Client(connectionId).SendAsync("OnAlarmChanged", changed),
DebugViewSnapshot snapshot =>
Clients.Client(connectionId).SendAsync("OnSnapshot", snapshot),
hubClients.Client(connectionId).SendAsync("OnSnapshot", snapshot),
_ => Task.CompletedTask
};
},
onTerminated: () =>
{
_ = Clients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected");
_ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected");
});
Context.Items[SessionIdKey] = session.SessionId;

View File

@@ -51,7 +51,8 @@ public class InstanceActor : ReceiveActor
private FlattenedConfiguration? _configuration;
// WP-25: Debug view subscribers
private readonly Dictionary<string, IActorRef> _debugSubscribers = new();
private readonly HashSet<string> _debugSubscriberCorrelationIds = new();
private ActorSelection? _siteCommActor;
// DCL manager actor reference for subscribing to tag values
private readonly IActorRef? _dclManager;
@@ -149,6 +150,9 @@ public class InstanceActor : ReceiveActor
base.PreStart();
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
// Resolve SiteCommunicationActor for routing debug stream events back to central
_siteCommActor = Context.ActorSelection("/user/site-communication");
// Asynchronously load static overrides from SQLite and pipe to self
var self = Self;
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
@@ -367,10 +371,10 @@ public class InstanceActor : ReceiveActor
// WP-23: Publish to site-wide stream
_streamManager?.PublishAlarmStateChanged(changed);
// Forward to debug subscribers
foreach (var sub in _debugSubscribers.Values)
// Forward to debug subscribers via SiteCommunicationActor → ClusterClient → central
foreach (var correlationId in _debugSubscriberCorrelationIds)
{
sub.Tell(changed);
_siteCommActor?.Tell(new DebugStreamEvent(correlationId, changed));
}
}
@@ -380,7 +384,7 @@ public class InstanceActor : ReceiveActor
private void HandleSubscribeDebugView(SubscribeDebugViewRequest request)
{
var subscriptionId = request.CorrelationId;
_debugSubscribers[subscriptionId] = Sender;
_debugSubscriberCorrelationIds.Add(subscriptionId);
// Build snapshot from current state
var now = DateTimeOffset.UtcNow;
@@ -407,9 +411,6 @@ public class InstanceActor : ReceiveActor
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);
@@ -420,8 +421,7 @@ public class InstanceActor : ReceiveActor
/// </summary>
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
{
_debugSubscribers.Remove(request.CorrelationId);
_streamManager?.RemoveSubscriber(Sender);
_debugSubscriberCorrelationIds.Remove(request.CorrelationId);
_logger.LogDebug(
"Debug view subscriber removed for {Instance}, correlationId={Id}",
@@ -479,10 +479,10 @@ public class InstanceActor : ReceiveActor
alarmActor.Tell(changed);
}
// Forward to debug subscribers
foreach (var sub in _debugSubscribers.Values)
// Forward to debug subscribers via SiteCommunicationActor → ClusterClient → central
foreach (var correlationId in _debugSubscriberCorrelationIds)
{
sub.Tell(changed);
_siteCommActor?.Tell(new DebugStreamEvent(correlationId, changed));
}
}