513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
337 lines
15 KiB
C#
337 lines
15 KiB
C#
using System.Diagnostics;
|
|
using System.Threading.Channels;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the per-subscriber coalescing pump behind R2 (gRPC event batching).
|
|
/// The pump is the only new behaviour on the site→central hot path, so its contract is
|
|
/// pinned directly rather than only through the server: the size cap, the time cap, the
|
|
/// flush when the channel writer completes, the "never reorder" guarantee, and the
|
|
/// un-negotiated (maxBatchEvents == 1) shape that keeps an older central working.
|
|
/// </summary>
|
|
public class SiteStreamEventBatcherTests
|
|
{
|
|
private const string Corr = "corr-batch";
|
|
|
|
private static SiteStreamEvent Event(int seq) => new()
|
|
{
|
|
CorrelationId = Corr,
|
|
AttributeChanged = new AttributeValueUpdate
|
|
{
|
|
InstanceUniqueName = "SiteA.Pump01",
|
|
AttributePath = "Modules.IO",
|
|
AttributeName = "Seq",
|
|
Value = seq.ToString(),
|
|
Quality = Quality.Good,
|
|
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch.AddSeconds(seq))
|
|
}
|
|
};
|
|
|
|
/// <summary>Flattens an emitted frame into the sequence numbers it carried, in order.</summary>
|
|
private static IEnumerable<int> Seqs(SiteStreamEvent frame)
|
|
{
|
|
if (frame.EventCase == SiteStreamEvent.EventOneofCase.Batch)
|
|
{
|
|
foreach (var inner in frame.Batch.Events)
|
|
yield return int.Parse(inner.AttributeChanged.Value);
|
|
yield break;
|
|
}
|
|
|
|
yield return int.Parse(frame.AttributeChanged.Value);
|
|
}
|
|
|
|
private sealed record PumpRun(List<SiteStreamEvent> Frames, List<int> FrameSizes, Task Completion);
|
|
|
|
private static PumpRun StartPump(
|
|
ChannelReader<SiteStreamEvent> reader,
|
|
int maxBatchEvents,
|
|
TimeSpan window,
|
|
CancellationToken ct = default)
|
|
{
|
|
var frames = new List<SiteStreamEvent>();
|
|
var sizes = new List<int>();
|
|
var task = SiteStreamEventBatcher.PumpAsync(
|
|
reader,
|
|
Corr,
|
|
maxBatchEvents,
|
|
window,
|
|
(evt, _) =>
|
|
{
|
|
lock (frames) { frames.Add(evt); }
|
|
return Task.CompletedTask;
|
|
},
|
|
size => { lock (frames) { sizes.Add(size); } },
|
|
ct);
|
|
return new PumpRun(frames, sizes, task);
|
|
}
|
|
|
|
// ── Size cap ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that a backlog is split into frames of at most the maximum event count.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SizeCap_SplitsABacklogIntoFramesOfAtMostMaxEvents()
|
|
{
|
|
// A backlog already sitting in the channel is drained without waiting, but never
|
|
// beyond the size cap — 250 queued events at a cap of 100 must come out as
|
|
// 100 + 100 + 50, in order, with nothing lost or duplicated.
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
for (var i = 0; i < 250; i++)
|
|
Assert.True(channel.Writer.TryWrite(Event(i)));
|
|
channel.Writer.Complete();
|
|
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
|
await run.Completion;
|
|
|
|
Assert.All(run.FrameSizes, s => Assert.True(s <= 100, $"frame carried {s} events (cap 100)"));
|
|
Assert.Equal([100, 100, 50], run.FrameSizes);
|
|
Assert.Equal(Enumerable.Range(0, 250), run.Frames.SelectMany(Seqs));
|
|
}
|
|
|
|
/// <summary>Verifies that a size cap of one emits plain per-event frames, the unnegotiated shape.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SizeCapOfOne_EmitsPlainPerEventFrames_TheUnnegotiatedShape()
|
|
{
|
|
// maxBatchEvents == 1 is what an un-negotiated subscription (an older central)
|
|
// gets. Every event must ride its own plain frame — never a Batch case, which
|
|
// that central's generated code cannot parse.
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
for (var i = 0; i < 5; i++)
|
|
channel.Writer.TryWrite(Event(i));
|
|
channel.Writer.Complete();
|
|
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 1, window: TimeSpan.FromMilliseconds(25));
|
|
await run.Completion;
|
|
|
|
Assert.Equal(5, run.Frames.Count);
|
|
Assert.All(run.Frames, f =>
|
|
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, f.EventCase));
|
|
Assert.All(run.Frames, f => Assert.Equal(Corr, f.CorrelationId));
|
|
Assert.Equal(Enumerable.Range(0, 5), run.Frames.SelectMany(Seqs));
|
|
}
|
|
|
|
// ── Time cap ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that an underfull batch is closed when the batching window elapses.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TimeCap_ClosesAnUnderfullBatchWhenTheWindowElapses()
|
|
{
|
|
// Two events arrive (a backlog, so the pump lingers), then the source goes quiet
|
|
// well short of the size cap. The window — not the cap — must close the batch,
|
|
// and it must do so within a bounded time rather than waiting for a 100th event
|
|
// that never comes.
|
|
var window = TimeSpan.FromMilliseconds(120);
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
channel.Writer.TryWrite(Event(0));
|
|
channel.Writer.TryWrite(Event(1));
|
|
|
|
var started = Stopwatch.GetTimestamp();
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window);
|
|
|
|
SiteStreamEvent frame;
|
|
while (true)
|
|
{
|
|
lock (run.Frames)
|
|
{
|
|
if (run.Frames.Count > 0) { frame = run.Frames[0]; break; }
|
|
}
|
|
Assert.True(Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(5),
|
|
"the window never closed the underfull batch");
|
|
await Task.Delay(5);
|
|
}
|
|
|
|
var elapsed = Stopwatch.GetElapsedTime(started);
|
|
|
|
channel.Writer.Complete();
|
|
await run.Completion;
|
|
|
|
Assert.Equal(SiteStreamEvent.EventOneofCase.Batch, frame.EventCase);
|
|
Assert.Equal([0, 1], Seqs(frame));
|
|
// The batch waited (it did not close instantly on the two queued events) and it
|
|
// closed on the window, not on a cap it never reached.
|
|
Assert.True(elapsed >= window - TimeSpan.FromMilliseconds(20),
|
|
$"batch closed after {elapsed.TotalMilliseconds:0.0} ms, before the {window.TotalMilliseconds:0} ms window");
|
|
}
|
|
|
|
/// <summary>Verifies that a lone event on a quiet stream is never delayed by the batching window.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task LoneEventOnAQuietStream_IsNeverDelayedByTheWindow()
|
|
{
|
|
// The latency contract: the window applies only AFTER a backlog has been observed.
|
|
// A single event on an idle stream must be emitted immediately as a plain frame,
|
|
// so per-event latency on a quiet site is unchanged by batching.
|
|
var window = TimeSpan.FromSeconds(5);
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window);
|
|
|
|
var started = Stopwatch.GetTimestamp();
|
|
channel.Writer.TryWrite(Event(7));
|
|
|
|
while (true)
|
|
{
|
|
lock (run.Frames)
|
|
{
|
|
if (run.Frames.Count > 0) break;
|
|
}
|
|
Assert.True(Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(3),
|
|
"a lone event was held by the coalescing window");
|
|
await Task.Delay(2);
|
|
}
|
|
|
|
var elapsed = Stopwatch.GetElapsedTime(started);
|
|
channel.Writer.Complete();
|
|
await run.Completion;
|
|
|
|
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, run.Frames[0].EventCase);
|
|
Assert.True(elapsed < TimeSpan.FromSeconds(1),
|
|
$"lone event took {elapsed.TotalMilliseconds:0.0} ms against a {window.TotalSeconds:0} s window");
|
|
}
|
|
|
|
// ── Flush on stream close ───────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that completing the writer flushes the in-flight batch before the pump returns.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task WriterCompletion_FlushesTheInFlightBatchBeforeReturning()
|
|
{
|
|
// The channel writer completing mid-window (the site stopping the relay actor and
|
|
// calling channel.Writer.TryComplete()) must flush what is already buffered rather
|
|
// than silently discarding it while waiting out the window.
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
channel.Writer.TryWrite(Event(0));
|
|
channel.Writer.TryWrite(Event(1));
|
|
|
|
// A long window guarantees the pump is lingering, not already past the emit.
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromSeconds(30));
|
|
|
|
await Task.Delay(100);
|
|
lock (run.Frames)
|
|
{
|
|
Assert.Empty(run.Frames); // still lingering
|
|
}
|
|
|
|
channel.Writer.Complete();
|
|
await run.Completion.WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Single(run.Frames);
|
|
Assert.Equal([0, 1], Seqs(run.Frames[0]));
|
|
}
|
|
|
|
/// <summary>Verifies that completing the writer with nothing buffered returns without emitting a frame.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task WriterCompletionWithNothingBuffered_ReturnsWithoutEmitting()
|
|
{
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
|
|
|
channel.Writer.Complete();
|
|
await run.Completion.WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Empty(run.Frames);
|
|
}
|
|
|
|
/// <summary>Verifies that cancellation ends the pump with an operation-canceled exception.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Cancellation_EndsThePumpWithOperationCanceled()
|
|
{
|
|
// Client disconnect / duplicate replacement / site shutdown. The pump must
|
|
// surface OperationCanceledException exactly as the pre-batching await-foreach
|
|
// loop did, so SiteStreamGrpcServer's existing catch and finally are unchanged.
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
using var cts = new CancellationTokenSource();
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25), cts.Token);
|
|
|
|
await cts.CancelAsync();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => run.Completion.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
// ── Ordering ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that event ordering is preserved across many batches while producers race.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Ordering_IsPreservedAcrossManyBatchesUnderAProducerRace()
|
|
{
|
|
// Batching is a framing change and nothing else: with a producer writing
|
|
// concurrently with the pump, the flattened output must be the exact input
|
|
// sequence — no reordering, no loss, no duplication, across many frames.
|
|
const int total = 5_000;
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 32, window: TimeSpan.FromMilliseconds(5));
|
|
|
|
var producer = Task.Run(async () =>
|
|
{
|
|
for (var i = 0; i < total; i++)
|
|
{
|
|
channel.Writer.TryWrite(Event(i));
|
|
if (i % 250 == 0) await Task.Yield();
|
|
}
|
|
channel.Writer.Complete();
|
|
});
|
|
|
|
await producer;
|
|
await run.Completion.WaitAsync(TimeSpan.FromSeconds(30));
|
|
|
|
Assert.Equal(Enumerable.Range(0, total), run.Frames.SelectMany(Seqs));
|
|
Assert.All(run.FrameSizes, s => Assert.InRange(s, 1, 32));
|
|
Assert.Equal(total, run.FrameSizes.Sum());
|
|
}
|
|
|
|
// ── Frame shape ─────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that a batch frame carries the correlation id once and blanks it on the inner events.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task BatchFrame_CarriesTheCorrelationIdOnceAndBlanksItOnInnerEvents()
|
|
{
|
|
// The byte saving batching exists for: the correlation id is stamped once on the
|
|
// enclosing frame, not repeated on every packed event. No consumer reads the
|
|
// inner value (SiteStreamGrpcClient.ForEachEvent ignores it).
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
for (var i = 0; i < 4; i++) channel.Writer.TryWrite(Event(i));
|
|
channel.Writer.Complete();
|
|
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
|
await run.Completion;
|
|
|
|
var frame = Assert.Single(run.Frames);
|
|
Assert.Equal(SiteStreamEvent.EventOneofCase.Batch, frame.EventCase);
|
|
Assert.Equal(Corr, frame.CorrelationId);
|
|
Assert.All(frame.Batch.Events, e => Assert.Equal(string.Empty, e.CorrelationId));
|
|
}
|
|
|
|
/// <summary>Verifies that per-event timestamps survive batching.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task PerEventTimestampsSurviveBatching()
|
|
{
|
|
// End-to-end latency measurement rides the per-event Timestamp; coalescing must
|
|
// not rewrite it to a single frame-level stamp.
|
|
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
|
for (var i = 0; i < 3; i++) channel.Writer.TryWrite(Event(i));
|
|
channel.Writer.Complete();
|
|
|
|
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
|
await run.Completion;
|
|
|
|
var frame = Assert.Single(run.Frames);
|
|
Assert.Equal(
|
|
[
|
|
DateTimeOffset.UnixEpoch,
|
|
DateTimeOffset.UnixEpoch.AddSeconds(1),
|
|
DateTimeOffset.UnixEpoch.AddSeconds(2)
|
|
],
|
|
frame.Batch.Events.Select(e => e.AttributeChanged.Timestamp.ToDateTimeOffset()));
|
|
}
|
|
}
|