test(gateway): end-to-end multi-subscriber fan-out via fake worker
Adds GatewayEndToEndMultiSubscriberTests covering three scenarios through the real gRPC StreamEvents path with AllowMultipleEventSubscribers=true: - Fan-out: two concurrent StreamEvents RPCs both receive every event the fake worker emits, in the same order (WorkerSequence matches, values indexed). - Independent cancellation: cancelling one subscriber's stream leaves the other receiving subsequent events; the session stays usable. - Cap enforcement: with MaxEventSubscribersPerSession=2 a third concurrent StreamEvents is rejected with gRPC ResourceExhausted while the first two keep streaming. Extends RecordingServerStreamWriter<T> with WaitForMessageCountAsync to allow deterministic bounded-timeout awaits for an N-message count without fixed sleeps.
This commit is contained in:
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="IServerStreamWriter{T}"/> that records every written message
|
||||
/// and lets a test await the first message with a timeout.
|
||||
/// and lets a test await the first message or a specific message count with a timeout.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The streamed message type.</typeparam>
|
||||
public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
@@ -12,6 +12,7 @@ public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly List<T> _messages = [];
|
||||
private readonly List<TaskCompletionSource<IReadOnlyList<T>>> _countWaiters = [];
|
||||
|
||||
/// <summary>Gets the messages written to this stream, in order.</summary>
|
||||
public IReadOnlyList<T> Messages
|
||||
@@ -33,12 +34,31 @@ public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
List<TaskCompletionSource<IReadOnlyList<T>>>? satisfied = null;
|
||||
IReadOnlyList<T>? snapshot = null;
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_messages.Add(message);
|
||||
_firstMessage.TrySetResult(message);
|
||||
|
||||
// Check whether any count waiters are now satisfied.
|
||||
if (_countWaiters.Count > 0)
|
||||
{
|
||||
snapshot = _messages.ToArray();
|
||||
satisfied = _countWaiters.ToList();
|
||||
_countWaiters.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (satisfied is not null && snapshot is not null)
|
||||
{
|
||||
foreach (TaskCompletionSource<IReadOnlyList<T>> waiter in satisfied)
|
||||
{
|
||||
waiter.TrySetResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
_firstMessage.TrySetResult(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -47,4 +67,53 @@ public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
/// <returns>The first message written to this stream.</returns>
|
||||
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout) =>
|
||||
await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Waits until at least <paramref name="count"/> messages have been written, then returns
|
||||
/// the current snapshot. The wait is bounded by <paramref name="timeout"/>; if fewer than
|
||||
/// <paramref name="count"/> messages arrive within the timeout the call throws
|
||||
/// <see cref="TimeoutException"/> (surfaced as <see cref="OperationCanceledException"/>
|
||||
/// from <see cref="Task.WaitAsync(TimeSpan)"/>).
|
||||
/// </summary>
|
||||
/// <param name="count">Minimum number of messages to wait for.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <returns>A snapshot of all messages received so far (at least <paramref name="count"/>).</returns>
|
||||
public async Task<IReadOnlyList<T>> WaitForMessageCountAsync(int count, TimeSpan timeout)
|
||||
{
|
||||
TaskCompletionSource<IReadOnlyList<T>>? tcs = null;
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_messages.Count >= count)
|
||||
{
|
||||
return _messages.ToArray();
|
||||
}
|
||||
|
||||
tcs = new TaskCompletionSource<IReadOnlyList<T>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_countWaiters.Add(tcs);
|
||||
}
|
||||
|
||||
// Poll: re-check each time any message arrives. The TCS is satisfied on EVERY write,
|
||||
// but the caller may need more messages, so we loop until the count is met.
|
||||
while (true)
|
||||
{
|
||||
IReadOnlyList<T> snapshot = await tcs.Task.WaitAsync(timeout).ConfigureAwait(false);
|
||||
if (snapshot.Count >= count)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Not enough yet — register a new waiter and keep waiting.
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_messages.Count >= count)
|
||||
{
|
||||
return _messages.ToArray();
|
||||
}
|
||||
|
||||
tcs = new TaskCompletionSource<IReadOnlyList<T>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_countWaiters.Add(tcs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user