feat(sitestream): add SubscribeSiteAlarms site-wide alarm-only broadcast (plan #10 T1)

Adds SiteStreamManager.SubscribeSiteAlarms(IActorRef) — same broadcast-hub
wiring as Subscribe(...) but with no per-instance filter and forwarding only
AlarmStateChanged events (AttributeValueChanged dropped). Returns a subscription
id torn down via the existing Unsubscribe, symmetric with the per-instance
subscribe. Backs the Task 2 SubscribeSite gRPC server handler.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 11:39:42 -04:00
parent 84112db344
commit b14a4dbc6b
2 changed files with 85 additions and 0 deletions
@@ -20,6 +20,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
/// </summary>
public class SiteStreamManager : ISiteStreamSubscriber
{
/// <summary>Sentinel instance name recorded for site-wide (non-instance-scoped) subscriptions.</summary>
private const string SiteWideInstanceName = "*";
private ActorSystem? _system;
private IMaterializer? _materializer;
private readonly int _bufferSize;
@@ -117,6 +120,44 @@ public class SiteStreamManager : ISiteStreamSubscriber
return subscriptionId;
}
/// <summary>
/// Subscribe to ALARM events for ALL instances on the site (no per-instance
/// filter). Only <see cref="AlarmStateChanged"/> events are forwarded;
/// <see cref="AttributeValueChanged"/> events are dropped (attributes are far
/// higher-volume and the aggregated Alarm Summary never shows them). Same
/// broadcast-hub wiring as <see cref="Subscribe"/>, and the returned
/// subscription id is torn down via <see cref="Unsubscribe"/> exactly like the
/// per-instance variant.
/// </summary>
/// <param name="subscriber">The actor that receives forwarded <see cref="AlarmStateChanged"/> events.</param>
/// <returns>A subscription id to pass to <see cref="Unsubscribe"/>.</returns>
public string SubscribeSiteAlarms(IActorRef subscriber)
{
if (_hubSource is null || _materializer is null)
throw new InvalidOperationException("SiteStreamManager.Initialize must be called before SubscribeSiteAlarms");
var subscriptionId = Guid.NewGuid().ToString();
var capturedSubscriber = subscriber;
var killSwitch = _hubSource
.Where(ev => ev is AlarmStateChanged)
.Buffer(_bufferSize, OverflowStrategy.DropHead)
.ViaMaterialized(KillSwitches.Single<ISiteStreamEvent>(), Keep.Right)
.To(Sink.ForEach<ISiteStreamEvent>(ev => capturedSubscriber.Tell(ev)))
.Run(_materializer);
lock (_lock)
{
_subscriptions[subscriptionId] = new SubscriptionInfo(
SiteWideInstanceName, subscriber, killSwitch, DateTimeOffset.UtcNow);
}
_logger.LogDebug(
"Subscriber {SubscriptionId} registered for site-wide alarm events", subscriptionId);
return subscriptionId;
}
/// <summary>
/// Unsubscribe from instance events. Shuts down the per-subscriber
/// stream graph via its KillSwitch.
@@ -103,6 +103,50 @@ public class SiteStreamManagerTests : TestKit, IDisposable
probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void SubscribeSiteAlarms_ForwardsAlarmsForAllInstances_ButNotAttributes()
{
var probe = CreateTestProbe();
_streamManager.SubscribeSiteAlarms(probe.Ref);
// Alarm events from two different instances — both should arrive.
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow));
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
"Pump2", "LowFlow", AlarmState.Active, 2, DateTimeOffset.UtcNow));
// Attribute events must be filtered out entirely.
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow));
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
"Pump3", "Flow", "Flow", "42", "Good", DateTimeOffset.UtcNow));
// Collect the two alarms (order across instances is not guaranteed).
var received = new[]
{
probe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3)),
probe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3)),
};
var instances = received.Select(a => a.InstanceUniqueName).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "Pump1", "Pump2" }, instances);
// No attribute events (nor any further alarm) should be delivered.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void SubscribeSiteAlarms_SubscriptionRemovableViaUnsubscribe()
{
var probe = CreateTestProbe();
var id = _streamManager.SubscribeSiteAlarms(probe.Ref);
Assert.NotNull(id);
Assert.Equal(1, _streamManager.SubscriptionCount);
Assert.True(_streamManager.Unsubscribe(id));
Assert.Equal(0, _streamManager.SubscriptionCount);
}
[Fact]
public void RemoveSubscriber_RemovesAllSubscriptionsForActor()
{