9b5cb3dd9d
Every AttributeValueChanged/AlarmStateChanged rode its own gRPC message on
SiteStreamService; at target scale that is ~37.5k messages/s/site of pure
framing overhead. Coalesce them, additively, with no new RPC.
Wire (sitestream.proto, regenerated via docker/regen-proto.sh sitestream):
InstanceStreamRequest.batching_supported = 3
SiteStreamRequest.batching_supported = 2
SiteStreamEvent.batch = 4 (new oneof case)
SiteStreamEventBatch { repeated SiteStreamEvent events = 1 }
The proto3 default of batching_supported IS the negotiation, and it is
load-bearing: a batch frame reaches a pre-R2 central as EventOneofCase.None,
whose ConvertToDomainEvent returns null — the whole batch would vanish with
no error anywhere. An old central cannot set the flag so it never receives
one; an old site ignores the unknown request field and keeps sending
per-event frames, which the new client's ForEachEvent handles as the
single-event case. Both skew directions are covered by tests that go through
a real proto serialize/parse round-trip.
Server: SiteStreamEventBatcher, a per-subscriber pump replacing the handler's
await-foreach/WriteAsync loop and byte-identical to it at a cap of 1. It
never delays a lone event — it drains the already-queued backlog for free and
lingers only once a backlog is proven — emits a single-event buffer as a
plain frame, preserves order and per-event Timestamps exactly, and flushes
what is buffered when the send channel's writer completes. It sits DOWNSTREAM
of StreamRelayActor's bounded DropOldest channel, so it changes framing only
and does not move the burst ceiling (deferred register row 31, which lives in
the shared publish stage upstream of the BroadcastHub).
Client: sets the flag on both subscriptions and unpacks in order into the
existing per-event pipeline, so SiteAlarmAggregatorActor,
DebugStreamBridgeActor, consumer-keepalive/orphan logic,
reconnect-on-graceful-completion, generation fencing, the (siteId, endpoint)
factory key and IsLive semantics are untouched.
Options (validated): GrpcStreamBatchMaxEvents 100 (1 disables),
GrpcStreamBatchWindow 25 ms — validated strictly under 250 ms, the load
test's end-to-end P99 threshold. Measured worst case (trickle-with-backlog,
window-bound rather than cap-bound): P50 13.8 ms, P99 25.4 ms, max 25.8 ms;
cap-bound case sent 600 queued events in 6 frames.
Telemetry: histogram scadabridge.site.stream.batch_size tagged by stream
kind, recorded only on negotiated streams (per-event otherwise). It rides
ScadaBridgeTelemetry.MeterName, already in the ObservedMeters allowlist.
Docs: Component-Communication.md gains an Event Batching section; CLAUDE.md's
gRPC streaming bullet records the wire shape and the negotiation rationale.
796 lines
31 KiB
C#
796 lines
31 KiB
C#
using System.Diagnostics.Metrics;
|
|
using System.Threading.Channels;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
public class SiteStreamGrpcServerTests : TestKit
|
|
{
|
|
private readonly ISiteStreamSubscriber _subscriber;
|
|
private readonly ILogger<SiteStreamGrpcServer> _logger;
|
|
|
|
public SiteStreamGrpcServerTests()
|
|
{
|
|
_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;
|
|
}
|
|
|
|
private SiteStreamGrpcServer CreateServer(int maxStreams = 100)
|
|
{
|
|
return new SiteStreamGrpcServer(_subscriber, _logger, maxStreams);
|
|
}
|
|
|
|
private static InstanceStreamRequest MakeRequest(string correlationId = "corr-1", string instance = "Site1.Pump01")
|
|
{
|
|
return new InstanceStreamRequest
|
|
{
|
|
CorrelationId = correlationId,
|
|
InstanceUniqueName = instance
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RejectsWhenNotReady()
|
|
{
|
|
var server = CreateServer();
|
|
// Do NOT call SetReady()
|
|
|
|
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
var context = CreateMockContext();
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => server.SubscribeInstance(MakeRequest(), writer, context));
|
|
|
|
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RejectsWhenMaxStreamsReached()
|
|
{
|
|
var server = CreateServer(maxStreams: 1);
|
|
server.SetReady(Sys);
|
|
|
|
// Start one stream that blocks
|
|
var cts1 = new CancellationTokenSource();
|
|
var context1 = CreateMockContext(cts1.Token);
|
|
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
|
|
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
|
MakeRequest("corr-1"), writer1, context1));
|
|
|
|
// Wait for the first stream to register
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
// Second stream should be rejected
|
|
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
var context2 = CreateMockContext();
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => server.SubscribeInstance(MakeRequest("corr-2"), writer2, context2));
|
|
|
|
Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
|
|
|
|
// Clean up first stream
|
|
cts1.Cancel();
|
|
await stream1Task;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelsDuplicateCorrelationId()
|
|
{
|
|
var server = CreateServer();
|
|
server.SetReady(Sys);
|
|
|
|
var cts1 = new CancellationTokenSource();
|
|
var context1 = CreateMockContext(cts1.Token);
|
|
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
|
|
// Start first stream
|
|
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
|
MakeRequest("corr-dup"), writer1, context1));
|
|
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
// Start second stream with same correlationId -- should cancel first
|
|
var cts2 = new CancellationTokenSource();
|
|
var context2 = CreateMockContext(cts2.Token);
|
|
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
|
|
var stream2Task = Task.Run(() => server.SubscribeInstance(
|
|
MakeRequest("corr-dup"), writer2, context2));
|
|
|
|
// First stream should complete (cancelled by duplicate replacement)
|
|
await stream1Task;
|
|
|
|
// Second stream should be active
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
// Clean up
|
|
cts2.Cancel();
|
|
await stream2Task;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleansUpOnCancellation()
|
|
{
|
|
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.SubscribeInstance(
|
|
MakeRequest("corr-cleanup"), writer, context));
|
|
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
cts.Cancel();
|
|
await streamTask;
|
|
|
|
Assert.Equal(0, server.ActiveStreamCount);
|
|
}
|
|
|
|
// --- Host-017 / REQ-HOST-7: site-shutdown ordering ---
|
|
|
|
[Fact]
|
|
public async Task Host017_CancelAllStreams_CancelsActiveStreamsAndRefusesNewOnes()
|
|
{
|
|
// REQ-HOST-7 step (1)+(2): on CoordinatedShutdown the gRPC server must
|
|
// stop accepting new streams AND cancel every active stream so the
|
|
// client observes a clean Cancelled (not a silent stream that only
|
|
// times out via keepalive). Program.cs registers
|
|
// ApplicationStopping → CancelAllStreams(); this test exercises the
|
|
// server-side guarantee in isolation.
|
|
var server = CreateServer();
|
|
server.SetReady(Sys);
|
|
|
|
var cts1 = new CancellationTokenSource();
|
|
var context1 = CreateMockContext(cts1.Token);
|
|
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
|
|
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
|
MakeRequest("corr-shutdown-1"), writer1, context1));
|
|
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
// Begin shutdown — flip the flag AND cancel the active stream.
|
|
server.CancelAllStreams();
|
|
|
|
Assert.True(server.IsShuttingDown);
|
|
|
|
// Active stream's await foreach observes OCE and falls through finally
|
|
// → entry is removed from _activeStreams.
|
|
await stream1Task;
|
|
Assert.Equal(0, server.ActiveStreamCount);
|
|
|
|
// A second SubscribeInstance after shutdown is refused immediately
|
|
// with Unavailable rather than allowed to register a new stream.
|
|
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
var context2 = CreateMockContext();
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => server.SubscribeInstance(MakeRequest("corr-shutdown-2"), writer2, context2));
|
|
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
|
Assert.Contains("shutting", ex.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void Host017_CancelAllStreams_IsIdempotent()
|
|
{
|
|
// Repeated calls during a double-fire shutdown sequence must not throw.
|
|
var server = CreateServer();
|
|
server.SetReady(Sys);
|
|
|
|
server.CancelAllStreams();
|
|
server.CancelAllStreams();
|
|
|
|
Assert.True(server.IsShuttingDown);
|
|
Assert.Equal(0, server.ActiveStreamCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubscribesAndRemovesFromStreamManager()
|
|
{
|
|
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.SubscribeInstance(
|
|
MakeRequest("corr-sub", "Site1.Motor01"), writer, context));
|
|
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
// Verify Subscribe was called
|
|
_subscriber.Received(1).Subscribe("Site1.Motor01", Arg.Any<IActorRef>());
|
|
|
|
cts.Cancel();
|
|
await streamTask;
|
|
|
|
// Verify RemoveSubscriber was called
|
|
_subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WritesEventsToResponseStream()
|
|
{
|
|
var server = CreateServer();
|
|
server.SetReady(Sys);
|
|
|
|
// Capture the relay actor so we can send it events
|
|
IActorRef? capturedActor = null;
|
|
_subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
|
.Returns(ci =>
|
|
{
|
|
capturedActor = ci.Arg<IActorRef>();
|
|
return "sub-write";
|
|
});
|
|
|
|
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.SubscribeInstance(
|
|
MakeRequest("corr-write", "Site1.Pump01"), writer, context));
|
|
|
|
await WaitForConditionAsync(() => capturedActor != null);
|
|
|
|
// Send a domain event to the relay actor
|
|
var ts = DateTimeOffset.UtcNow;
|
|
capturedActor!.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
|
"Site1.Pump01", "Path", "Attr", 99.5, "Good", ts));
|
|
|
|
// Wait for event to be written
|
|
await WaitForConditionAsync(() => writtenEvents.Count >= 1);
|
|
|
|
Assert.Single(writtenEvents);
|
|
Assert.Equal("corr-write", writtenEvents[0].CorrelationId);
|
|
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, writtenEvents[0].EventCase);
|
|
|
|
cts.Cancel();
|
|
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")]
|
|
[InlineData("")]
|
|
[InlineData("$weird")]
|
|
public async Task RejectsCorrelationIdThatIsNotActorNameSafe(string badCorrelationId)
|
|
{
|
|
// Communication-014 regression: a public gRPC SubscribeInstance must not feed
|
|
// an untrusted correlation_id straight into an Akka actor name. An unsafe id
|
|
// must be rejected cleanly with InvalidArgument rather than escaping as an
|
|
// unhandled InvalidActorNameException.
|
|
var server = CreateServer();
|
|
server.SetReady(Sys);
|
|
|
|
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
var context = CreateMockContext();
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => server.SubscribeInstance(MakeRequest(badCorrelationId), writer, context));
|
|
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
Assert.Equal(0, server.ActiveStreamCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcceptsActorNameSafeCorrelationId()
|
|
{
|
|
// A normal GUID-style correlation id (what central always supplies) is accepted.
|
|
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.SubscribeInstance(
|
|
MakeRequest(Guid.NewGuid().ToString()), writer, context));
|
|
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
cts.Cancel();
|
|
await streamTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Comm021_SubscribeThrows_StopsRelayActorAndRemovesActiveStreamEntry()
|
|
{
|
|
// Communication-021 regression: SubscribeInstance creates a StreamRelayActor
|
|
// and registers an _activeStreams entry BEFORE calling _streamSubscriber.Subscribe.
|
|
// If Subscribe throws (e.g. stale instance, site runtime shutting down) and the
|
|
// pre-fix code lets the throw escape without the wrapping try, the relay actor
|
|
// and the activeStreams entry both leak. The fix wraps the Subscribe call so the
|
|
// catch deterministically stops the actor and removes the entry before re-throw.
|
|
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
|
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
|
.Returns<string>(_ => throw new InvalidOperationException("instance not found"));
|
|
|
|
var server = new SiteStreamGrpcServer(subscriber, _logger);
|
|
server.SetReady(Sys);
|
|
|
|
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
var context = CreateMockContext();
|
|
|
|
// The InvalidOperationException is expected to propagate (the gRPC stack maps
|
|
// unhandled throws to Internal); the load-bearing assertion is the cleanup.
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => server.SubscribeInstance(MakeRequest("corr-comm021"), writer, context));
|
|
|
|
// _activeStreams entry was inserted before Subscribe was called; the catch
|
|
// must remove it so a follow-up subscription with the same correlation id is
|
|
// not blocked, and the relay actor must be stopped so it does not leak.
|
|
Assert.Equal(0, server.ActiveStreamCount);
|
|
|
|
// RemoveSubscriber must NOT have been called (Subscribe never returned a
|
|
// subscription id) — verifying we hit the catch path, not the finally path.
|
|
subscriber.DidNotReceive().RemoveSubscriber(Arg.Any<IActorRef>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SiteConnectionUpGauge_GoesToOneOnConnect_AndBackToZeroOnCancel()
|
|
{
|
|
// Telemetry follow-on: the scadabridge.site.connection.up gauge must read
|
|
// exactly 1 while a site stream is established and return to 0 once the
|
|
// stream terminates on the cancel path — proving SiteConnectionOpened() is
|
|
// matched by exactly one SiteConnectionClosed() in the handler's finally.
|
|
var server = CreateServer();
|
|
server.SetReady(Sys);
|
|
|
|
long ReadGauge()
|
|
{
|
|
long observed = 0;
|
|
using var listener = new MeterListener();
|
|
listener.InstrumentPublished = (instrument, l) =>
|
|
{
|
|
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName &&
|
|
instrument.Name == "scadabridge.site.connection.up")
|
|
{
|
|
l.EnableMeasurementEvents(instrument);
|
|
}
|
|
};
|
|
listener.SetMeasurementEventCallback<long>((_, measurement, _, _) => observed = measurement);
|
|
listener.Start();
|
|
listener.RecordObservableInstruments();
|
|
return observed;
|
|
}
|
|
|
|
var baseline = ReadGauge();
|
|
|
|
var cts = new CancellationTokenSource();
|
|
var context = CreateMockContext(cts.Token);
|
|
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
|
|
var streamTask = Task.Run(() => server.SubscribeInstance(
|
|
MakeRequest("corr-gauge", "Site1.Pump01"), writer, context));
|
|
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
|
|
|
// While the stream is up the gauge is one above whatever baseline other
|
|
// (possibly parallel) tests left behind — read relative so the assertion
|
|
// is robust to test interleaving on the process-wide static counter.
|
|
//
|
|
// SiteConnectionOpened() runs AFTER the _activeStreams insertion that
|
|
// WaitForConditionAsync(ActiveStreamCount == 1) keys off (see
|
|
// SiteStreamGrpcServer.SubscribeInstance), so a one-shot ReadGauge() here
|
|
// can race the increment under full-suite CPU oversubscription. Poll the
|
|
// gauge with a generous timeout until it reaches baseline + 1.
|
|
AwaitAssert(() => Assert.Equal(baseline + 1, ReadGauge()), TimeSpan.FromSeconds(5));
|
|
|
|
cts.Cancel();
|
|
await streamTask;
|
|
await WaitForConditionAsync(() => server.ActiveStreamCount == 0);
|
|
|
|
// After the cancel path runs the finally, the gauge is balanced back to
|
|
// the baseline — no leaked "up" count. Poll here too: the gauge decrement
|
|
// and the stream-count drop are independent observations, so under load
|
|
// give the finally's SiteConnectionClosed() time to land.
|
|
AwaitAssert(() => Assert.Equal(baseline, ReadGauge()), TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void SetReady_AllowsStreamCreation()
|
|
{
|
|
var server = CreateServer();
|
|
// Initially not ready -- just verify the property works
|
|
server.SetReady(Sys);
|
|
// No assertion needed -- the other tests verify that SetReady enables streaming
|
|
Assert.Equal(0, server.ActiveStreamCount);
|
|
}
|
|
|
|
private static ServerCallContext CreateMockContext(CancellationToken cancellationToken = default)
|
|
{
|
|
var context = Substitute.For<ServerCallContext>();
|
|
context.CancellationToken.Returns(cancellationToken);
|
|
return context;
|
|
}
|
|
|
|
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMs = 5000)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
|
while (!condition() && DateTime.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(25);
|
|
}
|
|
|
|
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
|
|
}
|
|
|
|
// ── WP2.3: the site-wide alarm feed gets its OWN, larger send channel ──
|
|
|
|
[Fact]
|
|
public void SiteAlarmStream_HasItsOwnLargerChannel_ThanTheDebugView()
|
|
{
|
|
// Sharing the Debug View's 1000-slot DropOldest channel meant an alarm burst during
|
|
// a WAN stall silently evicted operator-visible transitions to make room for
|
|
// diagnostics traffic. The two feeds are now sized independently.
|
|
var options = Microsoft.Extensions.Options.Options.Create(new CommunicationOptions());
|
|
var server = new SiteStreamGrpcServer(_subscriber, _logger, options);
|
|
|
|
Assert.Equal(1000, server.InstanceChannelCapacity);
|
|
Assert.Equal(20_000, server.SiteAlarmChannelCapacity);
|
|
Assert.True(server.SiteAlarmChannelCapacity > server.InstanceChannelCapacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChannelCapacities_AreBoundFromOptions_AndFloorAtOne()
|
|
{
|
|
var options = Microsoft.Extensions.Options.Options.Create(new CommunicationOptions
|
|
{
|
|
GrpcInstanceStreamChannelCapacity = 42,
|
|
GrpcSiteAlarmStreamChannelCapacity = 4242,
|
|
});
|
|
var server = new SiteStreamGrpcServer(_subscriber, _logger, options);
|
|
|
|
Assert.Equal(42, server.InstanceChannelCapacity);
|
|
Assert.Equal(4242, server.SiteAlarmChannelCapacity);
|
|
|
|
// A misconfigured zero/negative capacity must not throw at channel-construction
|
|
// time deep inside a live RPC — it floors at one instead.
|
|
var degenerate = new SiteStreamGrpcServer(_subscriber, _logger,
|
|
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions
|
|
{
|
|
GrpcInstanceStreamChannelCapacity = 0,
|
|
GrpcSiteAlarmStreamChannelCapacity = -5,
|
|
}));
|
|
Assert.Equal(1, degenerate.InstanceChannelCapacity);
|
|
Assert.Equal(1, degenerate.SiteAlarmChannelCapacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void DroppedStreamEventCount_StartsAtZero()
|
|
{
|
|
// The raw counter behind scadabridge.site.stream.events_dropped — a fresh node has
|
|
// evicted nothing.
|
|
var server = CreateServer();
|
|
Assert.Equal(0, server.DroppedStreamEventCount);
|
|
}
|
|
|
|
// ── R2: gRPC event batching, and its negotiation ────────────────────────────
|
|
|
|
[Fact]
|
|
public void BatchOptions_AreBoundFromOptions_AndClampDegenerateValues()
|
|
{
|
|
var options = Microsoft.Extensions.Options.Options.Create(new CommunicationOptions());
|
|
var server = new SiteStreamGrpcServer(_subscriber, _logger, options);
|
|
|
|
Assert.Equal(100, server.StreamBatchMaxEvents);
|
|
Assert.Equal(TimeSpan.FromMilliseconds(25), server.StreamBatchWindow);
|
|
|
|
// CommunicationOptionsValidator fails the boot on these, but a host composed
|
|
// without validation must not blow up deep inside a live RPC.
|
|
var degenerate = new SiteStreamGrpcServer(_subscriber, _logger,
|
|
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions
|
|
{
|
|
GrpcStreamBatchMaxEvents = 0,
|
|
GrpcStreamBatchWindow = TimeSpan.FromMilliseconds(-5),
|
|
}));
|
|
Assert.Equal(1, degenerate.StreamBatchMaxEvents);
|
|
Assert.Equal(TimeSpan.Zero, degenerate.StreamBatchWindow);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnnegotiatedSubscription_NeverEmitsABatchFrame()
|
|
{
|
|
// OLD-CENTRAL ↔ NEW-SITE skew. proto3 defaults batching_supported to false, which
|
|
// is exactly what a central built before R2 sends. The site must then keep to one
|
|
// event per frame — a Batch frame would arrive at that central as
|
|
// EventOneofCase.None and be silently dropped by its ConvertToDomainEvent.
|
|
var (server, capture, cts, streamTask, relay) =
|
|
await StartCapturingStreamAsync(batchingSupported: false);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
{
|
|
relay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
|
"Site1.Pump01", "Path", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
await WaitForConditionAsync(() => CountEvents(capture) >= 50, 10_000);
|
|
|
|
cts.Cancel();
|
|
await streamTask;
|
|
|
|
lock (capture)
|
|
{
|
|
Assert.All(capture, f => Assert.NotEqual(SiteStreamEvent.EventOneofCase.Batch, f.EventCase));
|
|
Assert.Equal(50, capture.Count);
|
|
}
|
|
|
|
GC.KeepAlive(server);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NegotiatedSubscription_CoalescesABacklogIntoFewerFramesThanEvents()
|
|
{
|
|
// NEW-CENTRAL ↔ NEW-SITE. A burst pushed at the relay faster than the pump drains
|
|
// it must come out in strictly fewer frames than events, with every event
|
|
// preserved in order.
|
|
const int burst = 400;
|
|
var (server, capture, cts, streamTask, relay) =
|
|
await StartCapturingStreamAsync(batchingSupported: true);
|
|
|
|
for (var i = 0; i < burst; i++)
|
|
{
|
|
relay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
|
"Site1.Pump01", "Path", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
await WaitForConditionAsync(() => CountEvents(capture) >= burst, 15_000);
|
|
|
|
cts.Cancel();
|
|
await streamTask;
|
|
|
|
List<SiteStreamEvent> frames;
|
|
lock (capture) { frames = [.. capture]; }
|
|
|
|
Assert.Equal(burst, frames.Sum(CountFrameEvents));
|
|
Assert.True(frames.Count < burst,
|
|
$"batching produced {frames.Count} frames for {burst} events — no coalescing happened");
|
|
Assert.Contains(frames, f => f.EventCase == SiteStreamEvent.EventOneofCase.Batch);
|
|
|
|
// Order is preserved end to end: the values arrive 0..burst-1 exactly once each.
|
|
var values = frames.SelectMany(FlattenAttributeValues).ToArray();
|
|
Assert.Equal(Enumerable.Range(0, burst).Select(i => i.ToString()), values);
|
|
|
|
GC.KeepAlive(server);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchSizeHistogram_IsRecordedOnlyForNegotiatedStreams()
|
|
{
|
|
// scadabridge.site.stream.batch_size rides ScadaBridgeTelemetry.MeterName, which is
|
|
// already in SiteServiceRegistration.ObservedMeters — an unlisted meter exports
|
|
// nothing, silently. Assert the instrument actually fires, and that it does NOT
|
|
// fire on an un-negotiated stream (where it would degenerate into a per-event
|
|
// instrument on the hottest path in the product).
|
|
var measurements = new List<int>();
|
|
using var listener = new MeterListener();
|
|
listener.InstrumentPublished = (instrument, l) =>
|
|
{
|
|
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName &&
|
|
instrument.Name == "scadabridge.site.stream.batch_size")
|
|
{
|
|
l.EnableMeasurementEvents(instrument);
|
|
}
|
|
};
|
|
listener.SetMeasurementEventCallback<int>((_, m, _, _) =>
|
|
{
|
|
lock (measurements) { measurements.Add(m); }
|
|
});
|
|
listener.Start();
|
|
|
|
// Un-negotiated: no measurements at all.
|
|
var (_, plainCapture, plainCts, plainTask, plainRelay) =
|
|
await StartCapturingStreamAsync(batchingSupported: false, correlationId: "corr-hist-off");
|
|
plainRelay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
|
"Site1.Pump01", "Path", "Attr", 1, "Good", DateTimeOffset.UtcNow));
|
|
await WaitForConditionAsync(() => CountEvents(plainCapture) >= 1);
|
|
plainCts.Cancel();
|
|
await plainTask;
|
|
|
|
lock (measurements) { Assert.Empty(measurements); }
|
|
|
|
// Negotiated: one measurement per emitted frame, each within the size cap.
|
|
var (_, capture, cts, streamTask, relay) =
|
|
await StartCapturingStreamAsync(batchingSupported: true, correlationId: "corr-hist-on");
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
relay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
|
"Site1.Pump01", "Path", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
|
}
|
|
await WaitForConditionAsync(() => CountEvents(capture) >= 20, 10_000);
|
|
cts.Cancel();
|
|
await streamTask;
|
|
|
|
lock (measurements)
|
|
{
|
|
Assert.NotEmpty(measurements);
|
|
Assert.Equal(20, measurements.Sum());
|
|
Assert.All(measurements, m => Assert.InRange(m, 1, SiteStreamGrpcServer.DefaultStreamBatchMaxEvents));
|
|
}
|
|
}
|
|
|
|
/// <summary>Total events carried across all captured frames (unpacking batch frames).</summary>
|
|
private static int CountEvents(List<SiteStreamEvent> capture)
|
|
{
|
|
lock (capture) { return capture.Sum(CountFrameEvents); }
|
|
}
|
|
|
|
private static int CountFrameEvents(SiteStreamEvent frame) =>
|
|
frame.EventCase == SiteStreamEvent.EventOneofCase.Batch ? frame.Batch.Events.Count : 1;
|
|
|
|
private static IEnumerable<string> FlattenAttributeValues(SiteStreamEvent frame)
|
|
{
|
|
if (frame.EventCase == SiteStreamEvent.EventOneofCase.Batch)
|
|
{
|
|
foreach (var inner in frame.Batch.Events)
|
|
yield return inner.AttributeChanged.Value;
|
|
yield break;
|
|
}
|
|
|
|
yield return frame.AttributeChanged.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a SubscribeInstance stream with the given batch negotiation, capturing every
|
|
/// written frame and handing back the relay actor so the test can drive domain events.
|
|
/// </summary>
|
|
private async Task<(SiteStreamGrpcServer Server, List<SiteStreamEvent> Capture,
|
|
CancellationTokenSource Cts, Task StreamTask, IActorRef Relay)>
|
|
StartCapturingStreamAsync(bool batchingSupported, string correlationId = "corr-batch")
|
|
{
|
|
IActorRef? capturedActor = null;
|
|
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
|
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
|
.Returns(ci =>
|
|
{
|
|
capturedActor = ci.Arg<IActorRef>();
|
|
return "sub-batch";
|
|
});
|
|
|
|
var server = new SiteStreamGrpcServer(subscriber, _logger,
|
|
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()));
|
|
server.SetReady(Sys);
|
|
|
|
var capture = new List<SiteStreamEvent>();
|
|
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
|
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask)
|
|
.AndDoes(ci =>
|
|
{
|
|
var frame = ci.Arg<SiteStreamEvent>();
|
|
lock (capture) { capture.Add(frame); }
|
|
});
|
|
|
|
var cts = new CancellationTokenSource();
|
|
var context = CreateMockContext(cts.Token);
|
|
|
|
var request = new InstanceStreamRequest
|
|
{
|
|
CorrelationId = correlationId,
|
|
InstanceUniqueName = "Site1.Pump01",
|
|
BatchingSupported = batchingSupported
|
|
};
|
|
|
|
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
|
await WaitForConditionAsync(() => capturedActor != null);
|
|
|
|
return (server, capture, cts, streamTask, capturedActor!);
|
|
}
|
|
}
|