feat(sitestream): add additive SubscribeSite alarm-only gRPC stream + server handler (plan #10 T2)

Adds an additive, site-wide, alarm-only gRPC stream backing the aggregated
Alarm Summary. Proto: new SiteStreamRequest { correlation_id } message + rpc
SubscribeSite(SiteStreamRequest) returns (stream SiteStreamEvent) on
SiteStreamService — purely additive, no field renumbering. Regenerated the
checked-in SiteStreamGrpc/*.cs.

Server: SubscribeInstance and the new SubscribeSite now delegate to a shared
RunSubscriptionStreamAsync helper (readiness/shutdown guards, correlation-id
validation, duplicate replacement, concurrency cap, bounded DropOldest channel,
relay actor, SiteConnectionOpened/Closed telemetry, guaranteed cleanup). The
only variation is the subscribe delegate: SubscribeSite calls
ISiteStreamSubscriber.SubscribeSiteAlarms (no per-instance filter). Added
SubscribeSiteAlarms to the ISiteStreamSubscriber contract (SiteStreamManager
already implements it from T1). StreamRelayActor reused unchanged — it already
drops IsConfiguredPlaceholder rows and maps the enriched AlarmStateUpdate.

Tests: SubscribeSite subscribes site alarms + removes on cancel, rejects unsafe
correlation ids, and relays a domain AlarmStateChanged as a proto
AlarmStateUpdate on the stream.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 11:46:36 -04:00
parent b14a4dbc6b
commit 9c631b47e1
6 changed files with 463 additions and 41 deletions
@@ -21,6 +21,8 @@ public class SiteStreamGrpcServerTests : TestKit
_subscriber = Substitute.For<ISiteStreamSubscriber>();
_subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
.Returns("sub-1");
_subscriber.SubscribeSiteAlarms(Arg.Any<IActorRef>())
.Returns("site-sub-1");
_logger = NullLogger<SiteStreamGrpcServer>.Instance;
}
@@ -266,6 +268,102 @@ public class SiteStreamGrpcServerTests : TestKit
await streamTask;
}
// --- SubscribeSite (site-wide, alarm-only aggregated stream, plan #10 T2) ---
private static SiteStreamRequest MakeSiteRequest(string correlationId = "site-corr-1")
=> new() { CorrelationId = correlationId };
[Fact]
public async Task SubscribeSite_SubscribesSiteAlarmsAndRemovesOnCancel()
{
var server = CreateServer();
server.SetReady(Sys);
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
var streamTask = Task.Run(() => server.SubscribeSite(
MakeSiteRequest("site-corr-sub"), writer, context));
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
// Site-wide handler must call SubscribeSiteAlarms (no instance filter),
// never the per-instance Subscribe.
_subscriber.Received(1).SubscribeSiteAlarms(Arg.Any<IActorRef>());
_subscriber.DidNotReceive().Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>());
cts.Cancel();
await streamTask;
_subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>());
Assert.Equal(0, server.ActiveStreamCount);
}
[Fact]
public async Task SubscribeSite_RejectsUnsafeCorrelationId()
{
var server = CreateServer();
server.SetReady(Sys);
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
var context = CreateMockContext();
var ex = await Assert.ThrowsAsync<RpcException>(
() => server.SubscribeSite(MakeSiteRequest("bad/id"), writer, context));
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
Assert.Equal(0, server.ActiveStreamCount);
}
[Fact]
public async Task SubscribeSite_RelaysAlarmStateChangedAsAlarmStateUpdate()
{
var server = CreateServer();
server.SetReady(Sys);
// Capture the relay actor spawned for the site-wide subscription.
IActorRef? capturedActor = null;
_subscriber.SubscribeSiteAlarms(Arg.Any<IActorRef>())
.Returns(ci =>
{
capturedActor = ci.Arg<IActorRef>();
return "site-sub-relay";
});
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
var writtenEvents = new List<SiteStreamEvent>();
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
var streamTask = Task.Run(() => server.SubscribeSite(
MakeSiteRequest("site-corr-write"), writer, context));
await WaitForConditionAsync(() => capturedActor != null);
// A real alarm transition for ANY instance must arrive as an AlarmStateUpdate.
capturedActor!.Tell(new Commons.Messages.Streaming.AlarmStateChanged(
"Site1.Pump01",
"HighPressure",
Commons.Types.Enums.AlarmState.Active,
700,
DateTimeOffset.UtcNow));
await WaitForConditionAsync(() => writtenEvents.Count >= 1);
Assert.Single(writtenEvents);
Assert.Equal("site-corr-write", writtenEvents[0].CorrelationId);
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, writtenEvents[0].EventCase);
Assert.Equal("Site1.Pump01", writtenEvents[0].AlarmChanged.InstanceUniqueName);
Assert.Equal("HighPressure", writtenEvents[0].AlarmChanged.AlarmName);
cts.Cancel();
await streamTask;
}
[Theory]
[InlineData("corr/with/slash")]
[InlineData("corr with space")]