using System.Diagnostics;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Grpc;
///
/// End-to-end coverage for R2 — gRPC event batching on the site→central
/// SiteStreamService stream.
///
///
/// The chain assembled here is the real one, mocking only the HTTP/2 transport:
/// domain event → real broadcast → real
/// handler → real StreamRelayActor → real
/// coalescing pump → proto serialize/parse round-trip (what the wire actually
/// carries) → real unpack → real
/// ConvertToDomainEvent. The serialize/parse step is what makes these
/// version-skew claims real rather than in-memory object graph assertions.
///
///
///
/// Version skew is covered in both directions. Negotiation is a single additive
/// request field (batching_supported), whose proto3 default of false IS the
/// compatibility mechanism: an old central cannot set it, so a new site never sends it a
/// frame case its generated code would drop; a new central always sets it, and an old
/// site ignores the unknown field and keeps sending per-event frames the new client
/// accepts unchanged.
///
///
public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : TestKit
{
private const string Instance = "SiteA.Pump01";
///
/// End-to-end latency threshold the target-scale load test asserts a P99 against
/// (measured P99 there: 4.57 ms). The coalescing window is the only latency batching
/// can add, so the batched pipe must stay comfortably inside the same budget.
///
private static readonly TimeSpan LatencyThreshold = TimeSpan.FromMilliseconds(250);
// ── Round trip: batched frames deliver every event, in order ────────────────
[Fact]
public async Task NegotiatedStream_RoundTripsEveryEventThroughTheWire_InOrder()
{
var (server, manager, frames, cts, streamTask) = await StartAsync(batchingSupported: true);
const int total = 600;
var t0 = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
for (var i = 0; i < total; i++)
{
manager.PublishAttributeValueChanged(new AttributeValueChanged(
Instance, "Modules.IO", "Seq", i, "Good", t0.AddMilliseconds(i)));
}
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 30_000);
cts.Cancel();
await streamTask;
var wire = SnapshotThroughTheWire(frames);
// Batching actually happened — otherwise this test proves nothing about batching.
Assert.Contains(wire, f => f.EventCase == SiteStreamEvent.EventOneofCase.Batch);
Assert.True(wire.Count < total,
$"{wire.Count} frames for {total} events — no coalescing happened");
var delivered = Unpack(wire);
Assert.Equal(total, delivered.Count);
// Every event, exactly once, in the order the site produced it — and with its OWN
// timestamp, not a frame-level one (end-to-end latency measurement rides it).
Assert.Equal(
Enumerable.Range(0, total).Select(i => i.ToString()),
delivered.Select(e => e.Value));
Assert.Equal(
Enumerable.Range(0, total).Select(i => t0.AddMilliseconds(i)),
delivered.Select(e => e.Timestamp));
output.WriteLine($"round-trip: {total} events in {wire.Count} frames " +
$"(mean {(double)total / wire.Count:0.0} events/frame)");
GC.KeepAlive(server);
}
// ── Latency cost of the default window ─────────────────────────────────────
[Fact]
public async Task DefaultWindow_KeepsPerEventLatencyFarBelowTheThreshold()
{
// A trickle-with-backlog workload is the case the coalescing window actually
// bites on: each burst is far short of the 100-event size cap, so the batch is
// closed by the 25 ms window rather than by the cap. That makes this the WORST
// case for added latency, not the best.
var options = new CommunicationOptions();
var (server, manager, frames, cts, streamTask) = await StartAsync(
batchingSupported: true, options: options);
const int bursts = 150;
const int perBurst = 8;
var stamps = new Dictionary();
var seq = 0;
for (var b = 0; b < bursts; b++)
{
for (var i = 0; i < perBurst; i++)
{
var ts = DateTimeOffset.UtcNow;
stamps[seq] = ts;
manager.PublishAttributeValueChanged(new AttributeValueChanged(
Instance, "Modules.IO", "Seq", seq, "Good", ts));
seq++;
}
await Task.Delay(5);
}
var total = seq;
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 60_000);
cts.Cancel();
await streamTask;
// Latency = the event's own site-side timestamp → the instant the frame carrying
// it was handed to the response stream. That interval contains the coalescing
// window and nothing else the pre-batching pipe did not already have.
var latencies = new List();
lock (frames)
{
foreach (var (frame, writtenAt) in frames)
{
foreach (var evt in Flatten(frame))
{
var s = int.Parse(evt.AttributeChanged.Value);
latencies.Add((writtenAt - stamps[s]).TotalMilliseconds);
}
}
}
latencies.Sort();
var p50 = latencies[(int)(latencies.Count * 0.50)];
var p99 = latencies[(int)(latencies.Count * 0.99)];
var max = latencies[^1];
output.WriteLine(
$"window={options.GrpcStreamBatchWindow.TotalMilliseconds:0} ms cap={options.GrpcStreamBatchMaxEvents} " +
$"events={latencies.Count} P50={p50:0.00} ms P99={p99:0.00} ms max={max:0.00} ms");
Assert.Equal(total, latencies.Count);
Assert.True(p99 < LatencyThreshold.TotalMilliseconds,
$"P99 {p99:0.00} ms exceeded the {LatencyThreshold.TotalMilliseconds:0} ms end-to-end threshold");
GC.KeepAlive(server);
}
// ── Version skew: OLD central ↔ NEW site ───────────────────────────────────
[Fact]
public async Task OldCentral_AgainstNewSite_NeverReceivesABatchFrame()
{
// An old central's InstanceStreamRequest bytes simply have no field 3 — build
// exactly those bytes and let the NEW site parse them, so the negotiation default
// is exercised off the wire rather than asserted on an object.
var oldCentralBytes = BuildLegacyInstanceRequest("corr-old-central", Instance);
var request = InstanceStreamRequest.Parser.ParseFrom(oldCentralBytes);
Assert.False(request.BatchingSupported);
var (server, manager, frames, cts, streamTask) = await StartAsync(request);
const int total = 300;
for (var i = 0; i < total; i++)
{
manager.PublishAttributeValueChanged(new AttributeValueChanged(
Instance, "Modules.IO", "Seq", i, "Good", DateTimeOffset.UtcNow));
}
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 30_000);
cts.Cancel();
await streamTask;
var wire = SnapshotThroughTheWire(frames);
// One event per frame, and — checked at the byte level, since that is what the
// old peer's parser sees — never the field-4 batch tag.
Assert.Equal(total, wire.Count);
Assert.All(wire, f =>
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, f.EventCase));
Assert.All(wire, f => Assert.DoesNotContain(4, FieldNumbers(f)));
GC.KeepAlive(server);
}
[Fact]
public void BatchFrameRidesFieldFour_WhichAPreBatchingParserWouldDropSilently()
{
// WHY negotiation is mandatory rather than "just send batches". A batch frame is a
// length-delimited field 4: an older generated parser skips it into unknown fields
// and reports EventOneofCase.None, whose ConvertToDomainEvent returns null — the
// whole batch would vanish with no error anywhere. The proto3 default on
// batching_supported is what guarantees such a peer never receives one.
var batch = new SiteStreamEvent
{
CorrelationId = "corr-shape",
Batch = new SiteStreamEventBatch
{
Events = { MakeAttributeEvent(1), MakeAttributeEvent(2) }
}
};
var fields = FieldNumbers(batch);
Assert.Contains(4, fields);
Assert.DoesNotContain(2, fields);
Assert.DoesNotContain(3, fields);
// Field 4 is length-delimited (wire type 2) — the shape an unknown-field-tolerant
// parser can skip without corrupting the rest of the message.
Assert.Equal(2u, WireTypeOfField(batch, 4));
// And the per-event frames a pre-batching site emits still parse and convert on the
// NEW client (the other skew direction, at the same byte level).
var plain = SiteStreamEvent.Parser.ParseFrom(MakeAttributeEvent(7).ToByteArray());
Assert.NotNull(SiteStreamGrpcClient.ConvertToDomainEvent(plain));
}
// ── Version skew: NEW central ↔ OLD site ───────────────────────────────────
[Fact]
public async Task NewCentral_AgainstOldSite_StillReceivesEveryEvent()
{
// An old site ignores batching_supported and emits per-event frames. That emission
// shape is exactly what the current server produces with batching off, so drive
// the real server that way and feed the result through the NEW client's unpack —
// which must handle the single-event case identically to before R2.
var (server, manager, frames, cts, streamTask) = await StartAsync(batchingSupported: false);
const int total = 200;
var t0 = new DateTimeOffset(2026, 8, 15, 13, 0, 0, TimeSpan.Zero);
for (var i = 0; i < total; i++)
{
manager.PublishAttributeValueChanged(new AttributeValueChanged(
Instance, "Modules.IO", "Seq", i, "Good", t0.AddMilliseconds(i)));
}
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 30_000);
cts.Cancel();
await streamTask;
var wire = SnapshotThroughTheWire(frames);
Assert.Equal(total, wire.Count);
var delivered = Unpack(wire);
Assert.Equal(
Enumerable.Range(0, total).Select(i => i.ToString()),
delivered.Select(e => e.Value));
Assert.Equal(
Enumerable.Range(0, total).Select(i => t0.AddMilliseconds(i)),
delivered.Select(e => e.Timestamp));
GC.KeepAlive(server);
}
// ── Helpers ────────────────────────────────────────────────────────────────
private static SiteStreamEvent MakeAttributeEvent(int seq) => new()
{
CorrelationId = "corr-shape",
AttributeChanged = new AttributeValueUpdate
{
InstanceUniqueName = Instance,
AttributePath = "Modules.IO",
AttributeName = "Seq",
Value = seq.ToString(),
Quality = Quality.Good,
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch)
}
};
///
/// Serializes an InstanceStreamRequest the way a central built BEFORE R2 would:
/// fields 1 and 2 only, with no batching_supported on the wire at all.
///
private static byte[] BuildLegacyInstanceRequest(string correlationId, string instance)
{
using var ms = new MemoryStream();
var output = new CodedOutputStream(ms);
output.WriteTag(1, WireFormat.WireType.LengthDelimited);
output.WriteString(correlationId);
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
output.WriteString(instance);
output.Flush();
return ms.ToArray();
}
/// Top-level field numbers present in a serialized message.
private static HashSet FieldNumbers(IMessage message)
{
var fields = new HashSet();
var input = new CodedInputStream(message.ToByteArray());
uint tag;
while ((tag = input.ReadTag()) != 0)
{
fields.Add(WireFormat.GetTagFieldNumber(tag));
input.SkipLastField();
}
return fields;
}
/// Wire type of the given top-level field number in a serialized message.
private static uint WireTypeOfField(IMessage message, int fieldNumber)
{
var input = new CodedInputStream(message.ToByteArray());
uint tag;
while ((tag = input.ReadTag()) != 0)
{
if (WireFormat.GetTagFieldNumber(tag) == fieldNumber)
return (uint)WireFormat.GetTagWireType(tag);
input.SkipLastField();
}
throw new InvalidOperationException($"field {fieldNumber} not present");
}
private static IEnumerable Flatten(SiteStreamEvent frame)
{
if (frame.EventCase == SiteStreamEvent.EventOneofCase.Batch)
{
foreach (var inner in frame.Batch.Events) yield return inner;
yield break;
}
yield return frame;
}
private static int TotalEvents(List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> frames)
{
lock (frames) { return frames.Sum(f => Flatten(f.Frame).Count()); }
}
///
/// Takes the captured frames through a real protobuf serialize/parse round-trip — the
/// step that makes every claim in this file about wire compatibility a wire claim.
///
private static List SnapshotThroughTheWire(
List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> frames)
{
lock (frames)
{
return [.. frames.Select(f => SiteStreamEvent.Parser.ParseFrom(f.Frame.ToByteArray()))];
}
}
/// Unpacks wire frames through the REAL client path into domain events.
private static List Unpack(IEnumerable wire)
{
var delivered = new List();
foreach (var frame in wire)
{
SiteStreamGrpcClient.ForEachEvent(frame, e =>
{
if (SiteStreamGrpcClient.ConvertToDomainEvent(e) is AttributeValueChanged a)
delivered.Add(a);
});
}
return delivered;
}
private Task<(SiteStreamGrpcServer Server, SiteStreamManager Manager,
List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> Frames,
CancellationTokenSource Cts, Task StreamTask)>
StartAsync(bool batchingSupported, CommunicationOptions? options = null)
=> StartAsync(new InstanceStreamRequest
{
CorrelationId = "corr-batching",
InstanceUniqueName = Instance,
BatchingSupported = batchingSupported
}, options);
///
/// Brings up a real site broadcast hub + real gRPC server handler for the supplied
/// subscription request, capturing every written frame with the instant it was written.
///
private async Task<(SiteStreamGrpcServer Server, SiteStreamManager Manager,
List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> Frames,
CancellationTokenSource Cts, Task StreamTask)>
StartAsync(InstanceStreamRequest request, CommunicationOptions? options = null)
{
var manager = new SiteStreamManager(
new SiteRuntimeOptions { StreamBufferSize = 4096 },
NullLogger.Instance);
manager.Initialize(Sys);
var server = new SiteStreamGrpcServer(
manager,
NullLogger.Instance,
Options.Create(options ?? new CommunicationOptions()));
server.SetReady(Sys);
var frames = new List<(SiteStreamEvent, DateTimeOffset)>();
var writer = Substitute.For>();
writer.WriteAsync(Arg.Any(), Arg.Any())
.Returns(Task.CompletedTask)
.AndDoes(ci =>
{
var frame = ci.Arg();
var at = DateTimeOffset.UtcNow;
lock (frames) { frames.Add((frame, at)); }
});
var cts = new CancellationTokenSource();
var context = Substitute.For();
context.CancellationToken.Returns(cts.Token);
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
// The publish must not race the materialized subscription.
await WaitForConditionAsync(() => manager.SubscriptionCount == 1);
return (server, manager, frames, cts, streamTask);
}
private static async Task WaitForConditionAsync(Func condition, int timeoutMs = 5000)
{
var started = Stopwatch.GetTimestamp();
while (!condition() && Stopwatch.GetElapsedTime(started) < TimeSpan.FromMilliseconds(timeoutMs))
{
await Task.Delay(10);
}
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
}
}