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:
@@ -51,9 +51,9 @@ Both central and site clusters. Each side has communication actors that handle m
|
|||||||
|
|
||||||
### 6. Debug Streaming (Site → Central)
|
### 6. Debug Streaming (Site → Central)
|
||||||
- **Pattern**: Subscribe/push with initial snapshot (no polling).
|
- **Pattern**: Subscribe/push with initial snapshot (no polling).
|
||||||
- A **DebugStreamBridgeActor** (one per active debug session) is created on the central cluster by the **DebugStreamService**. The bridge actor sends a `SubscribeDebugViewRequest` to the site via `CentralCommunicationActor`, with itself as the `Sender`. The site's `InstanceActor` registers the bridge actor as the debug subscriber.
|
- A **DebugStreamBridgeActor** (one per active debug session) is created on the central cluster by the **DebugStreamService**. The bridge actor sends a `SubscribeDebugViewRequest` to the site via `CentralCommunicationActor`. The site's `InstanceActor` stores the subscription's correlation ID and replies with an initial snapshot via the ClusterClient reply path.
|
||||||
- Site requests a **snapshot** of all current attribute values and alarm states from the Instance Actor and sends it to the bridge actor.
|
- Site requests a **snapshot** of all current attribute values and alarm states from the Instance Actor and sends it back to the bridge actor (via the ClusterClient reply path, which works for immediate responses).
|
||||||
- Site then subscribes to the **site-wide Akka stream** filtered by the instance's unique name and forwards `AttributeValueChanged` and `AlarmStateChanged` events to the bridge actor in real time via Akka remoting.
|
- For ongoing events, the InstanceActor wraps `AttributeValueChanged` and `AlarmStateChanged` in a `DebugStreamEvent(correlationId, event)` message and sends it to the local `SiteCommunicationActor`. The SiteCommunicationActor forwards it to central via its own ClusterClient (`ClusterClient.Send("/user/central-communication", event)`). The `CentralCommunicationActor` looks up the bridge actor by correlation ID and delivers the event. This follows the same site→central pattern as health reports.
|
||||||
- The bridge actor forwards received events to the consumer via callbacks (Blazor component or SignalR hub).
|
- The bridge actor forwards received events to the consumer via callbacks (Blazor component or SignalR hub).
|
||||||
- Attribute value stream messages: `[InstanceUniqueName].[AttributePath].[AttributeName]`, value, quality, timestamp.
|
- Attribute value stream messages: `[InstanceUniqueName].[AttributePath].[AttributeName]`, value, quality, timestamp.
|
||||||
- Alarm state stream messages: `[InstanceUniqueName].[AlarmName]`, state (active/normal), priority, timestamp.
|
- Alarm state stream messages: `[InstanceUniqueName].[AlarmName]`, state (active/normal), priority, timestamp.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -85,6 +85,9 @@ public class CentralCommunicationActor : ReceiveActor
|
|||||||
|
|
||||||
// Route enveloped messages to sites
|
// Route enveloped messages to sites
|
||||||
Receive<SiteEnvelope>(HandleSiteEnvelope);
|
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)
|
private void HandleHeartbeat(HeartbeatMessage heartbeat)
|
||||||
@@ -93,6 +96,18 @@ public class CentralCommunicationActor : ReceiveActor
|
|||||||
Context.Parent.Tell(heartbeat);
|
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)
|
private void HandleSiteHealthReport(SiteHealthReport report)
|
||||||
{
|
{
|
||||||
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
|
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
_centralClient?.Tell(
|
_centralClient?.Tell(
|
||||||
new ClusterClient.Send("/user/central-communication", msg), Self);
|
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()
|
protected override void PreStart()
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ public class DebugStreamHub : Hub
|
|||||||
private const string SessionIdKey = "DebugStreamSessionId";
|
private const string SessionIdKey = "DebugStreamSessionId";
|
||||||
|
|
||||||
private readonly DebugStreamService _debugStreamService;
|
private readonly DebugStreamService _debugStreamService;
|
||||||
|
private readonly IHubContext<DebugStreamHub> _hubContext;
|
||||||
private readonly ILogger<DebugStreamHub> _logger;
|
private readonly ILogger<DebugStreamHub> _logger;
|
||||||
|
|
||||||
public DebugStreamHub(DebugStreamService debugStreamService, ILogger<DebugStreamHub> logger)
|
public DebugStreamHub(
|
||||||
|
DebugStreamService debugStreamService,
|
||||||
|
IHubContext<DebugStreamHub> hubContext,
|
||||||
|
ILogger<DebugStreamHub> logger)
|
||||||
{
|
{
|
||||||
_debugStreamService = debugStreamService;
|
_debugStreamService = debugStreamService;
|
||||||
|
_hubContext = hubContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +110,10 @@ public class DebugStreamHub : Hub
|
|||||||
|
|
||||||
try
|
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(
|
var session = await _debugStreamService.StartStreamAsync(
|
||||||
instanceId,
|
instanceId,
|
||||||
onEvent: evt =>
|
onEvent: evt =>
|
||||||
@@ -113,17 +122,17 @@ public class DebugStreamHub : Hub
|
|||||||
_ = evt switch
|
_ = evt switch
|
||||||
{
|
{
|
||||||
AttributeValueChanged changed =>
|
AttributeValueChanged changed =>
|
||||||
Clients.Client(connectionId).SendAsync("OnAttributeChanged", changed),
|
hubClients.Client(connectionId).SendAsync("OnAttributeChanged", changed),
|
||||||
AlarmStateChanged changed =>
|
AlarmStateChanged changed =>
|
||||||
Clients.Client(connectionId).SendAsync("OnAlarmChanged", changed),
|
hubClients.Client(connectionId).SendAsync("OnAlarmChanged", changed),
|
||||||
DebugViewSnapshot snapshot =>
|
DebugViewSnapshot snapshot =>
|
||||||
Clients.Client(connectionId).SendAsync("OnSnapshot", snapshot),
|
hubClients.Client(connectionId).SendAsync("OnSnapshot", snapshot),
|
||||||
_ => Task.CompletedTask
|
_ => Task.CompletedTask
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onTerminated: () =>
|
onTerminated: () =>
|
||||||
{
|
{
|
||||||
_ = Clients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected");
|
_ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
Context.Items[SessionIdKey] = session.SessionId;
|
Context.Items[SessionIdKey] = session.SessionId;
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public class InstanceActor : ReceiveActor
|
|||||||
private FlattenedConfiguration? _configuration;
|
private FlattenedConfiguration? _configuration;
|
||||||
|
|
||||||
// WP-25: Debug view subscribers
|
// 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
|
// DCL manager actor reference for subscribing to tag values
|
||||||
private readonly IActorRef? _dclManager;
|
private readonly IActorRef? _dclManager;
|
||||||
@@ -149,6 +150,9 @@ public class InstanceActor : ReceiveActor
|
|||||||
base.PreStart();
|
base.PreStart();
|
||||||
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
|
_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
|
// Asynchronously load static overrides from SQLite and pipe to self
|
||||||
var self = Self;
|
var self = Self;
|
||||||
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
|
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
|
||||||
@@ -367,10 +371,10 @@ public class InstanceActor : ReceiveActor
|
|||||||
// WP-23: Publish to site-wide stream
|
// WP-23: Publish to site-wide stream
|
||||||
_streamManager?.PublishAlarmStateChanged(changed);
|
_streamManager?.PublishAlarmStateChanged(changed);
|
||||||
|
|
||||||
// Forward to debug subscribers
|
// Forward to debug subscribers via SiteCommunicationActor → ClusterClient → central
|
||||||
foreach (var sub in _debugSubscribers.Values)
|
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)
|
private void HandleSubscribeDebugView(SubscribeDebugViewRequest request)
|
||||||
{
|
{
|
||||||
var subscriptionId = request.CorrelationId;
|
var subscriptionId = request.CorrelationId;
|
||||||
_debugSubscribers[subscriptionId] = Sender;
|
_debugSubscriberCorrelationIds.Add(subscriptionId);
|
||||||
|
|
||||||
// Build snapshot from current state
|
// Build snapshot from current state
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
@@ -407,9 +411,6 @@ public class InstanceActor : ReceiveActor
|
|||||||
|
|
||||||
Sender.Tell(snapshot);
|
Sender.Tell(snapshot);
|
||||||
|
|
||||||
// Also register with stream manager for filtered events
|
|
||||||
_streamManager?.Subscribe(_instanceUniqueName, Sender);
|
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Debug view subscriber added for {Instance}, subscriptionId={Id}",
|
"Debug view subscriber added for {Instance}, subscriptionId={Id}",
|
||||||
_instanceUniqueName, subscriptionId);
|
_instanceUniqueName, subscriptionId);
|
||||||
@@ -420,8 +421,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
|
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
|
||||||
{
|
{
|
||||||
_debugSubscribers.Remove(request.CorrelationId);
|
_debugSubscriberCorrelationIds.Remove(request.CorrelationId);
|
||||||
_streamManager?.RemoveSubscriber(Sender);
|
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Debug view subscriber removed for {Instance}, correlationId={Id}",
|
"Debug view subscriber removed for {Instance}, correlationId={Id}",
|
||||||
@@ -479,10 +479,10 @@ public class InstanceActor : ReceiveActor
|
|||||||
alarmActor.Tell(changed);
|
alarmActor.Tell(changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward to debug subscribers
|
// Forward to debug subscribers via SiteCommunicationActor → ClusterClient → central
|
||||||
foreach (var sub in _debugSubscribers.Values)
|
foreach (var correlationId in _debugSubscriberCorrelationIds)
|
||||||
{
|
{
|
||||||
sub.Tell(changed);
|
_siteCommActor?.Tell(new DebugStreamEvent(correlationId, changed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
|||||||
MaxScriptCallDepth = 10,
|
MaxScriptCallDepth = 10,
|
||||||
ScriptExecutionTimeoutSeconds = 30
|
ScriptExecutionTimeoutSeconds = 30
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create a fake site-communication actor that unwraps DebugStreamEvent
|
||||||
|
// and forwards the inner event to TestActor (simulating the ClusterClient relay)
|
||||||
|
Sys.ActorOf(Props.Create(() => new DebugStreamEventForwarder(TestActor)), "site-communication");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test helper: stands in for SiteCommunicationActor, unwraps DebugStreamEvent
|
||||||
|
/// and forwards the inner event to a target actor.
|
||||||
|
/// </summary>
|
||||||
|
private class DebugStreamEventForwarder : ReceiveActor
|
||||||
|
{
|
||||||
|
public DebugStreamEventForwarder(IActorRef target)
|
||||||
|
{
|
||||||
|
Receive<DebugStreamEvent>(msg => target.Tell(msg.Event));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void IDisposable.Dispose()
|
void IDisposable.Dispose()
|
||||||
|
|||||||
Reference in New Issue
Block a user