0b4a1682c8
- StreamAlarms: slow subscribers are completed with AlarmFeedSubscriberOverflowException and surface as ResourceExhausted (like a StreamEvents overflow) instead of warning-with-stack + Unavailable. - GatewayAlarmMonitor.StreamAsync reuses the memoized alarm-set projection instead of cloning the whole set under the broadcast lock per attach; the prefix filter runs outside the lock. - StreamEvents/StreamAlarms pass the call token to response writes; a write failure after client cancel maps to Cancelled without a warning. - Every MxCommandReply.correlation_id now echoes the request's client_correlation_id (previously only constraint-denied replies did; normal replies leaked the internal envelope id).
146 lines
6.0 KiB
C#
146 lines
6.0 KiB
C#
using Grpc.Core;
|
||
|
||
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 or a specific message count with a timeout.
|
||
/// </summary>
|
||
/// <typeparam name="T">The streamed message type.</typeparam>
|
||
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
|
||
{
|
||
get
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
return _messages.ToArray();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Gets or sets options for writing messages to the stream.</summary>
|
||
public WriteOptions? WriteOptions { get; set; }
|
||
|
||
/// <summary>Gets the cancellation token passed to the most recent token-bearing write.</summary>
|
||
public CancellationToken LastWriteCancellationToken { get; private set; }
|
||
|
||
/// <summary>Records the supplied message unless the token is already cancelled.</summary>
|
||
/// <param name="message">The message to record.</param>
|
||
/// <param name="cancellationToken">Token that cancels the write.</param>
|
||
/// <returns>A completed task.</returns>
|
||
/// <remarks>
|
||
/// The interface default throws <see cref="NotSupportedException"/> for a cancelable token,
|
||
/// and the gateway passes the call's token on every streamed write, so a test writer must
|
||
/// override this overload the way the ASP.NET Core writer does.
|
||
/// </remarks>
|
||
public Task WriteAsync(T message, CancellationToken cancellationToken)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
LastWriteCancellationToken = cancellationToken;
|
||
return WriteAsync(message);
|
||
}
|
||
|
||
/// <summary>Records the supplied message.</summary>
|
||
/// <param name="message">The message to record.</param>
|
||
/// <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);
|
||
}
|
||
}
|
||
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>Waits for the first message to be written within the specified timeout.</summary>
|
||
/// <param name="timeout">Maximum time to wait for the first message.</param>
|
||
/// <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 a single deadline of <c>now + timeout</c>;
|
||
/// intermediate wakeups (when a message arrives but the count is not yet met) consume from
|
||
/// that same deadline so the total elapsed time never exceeds <paramref name="timeout"/>.
|
||
/// If fewer than <paramref name="count"/> messages arrive before the deadline the call
|
||
/// throws <see cref="OperationCanceledException"/>.
|
||
/// </summary>
|
||
/// <param name="count">Minimum number of messages to wait for.</param>
|
||
/// <param name="timeout">Maximum total time to wait, measured from the moment of the call.</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)
|
||
{
|
||
// Capture a single deadline so every iteration of the loop below draws from the
|
||
// same budget — using WaitAsync(timeout) per iteration would reset the clock on
|
||
// each intermediate wakeup, effectively giving N×timeout total budget.
|
||
using CancellationTokenSource deadlineCts = new(timeout);
|
||
CancellationToken deadlineToken = deadlineCts.Token;
|
||
|
||
TaskCompletionSource<IReadOnlyList<T>>? tcs = null;
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
if (_messages.Count >= count)
|
||
{
|
||
return _messages.ToArray();
|
||
}
|
||
|
||
tcs = new TaskCompletionSource<IReadOnlyList<T>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
_countWaiters.Add(tcs);
|
||
}
|
||
|
||
// 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(deadlineToken).ConfigureAwait(false);
|
||
if (snapshot.Count >= count)
|
||
{
|
||
return snapshot;
|
||
}
|
||
|
||
// Not enough yet — register a new waiter and keep waiting against the same deadline.
|
||
lock (_syncRoot)
|
||
{
|
||
if (_messages.Count >= count)
|
||
{
|
||
return _messages.ToArray();
|
||
}
|
||
|
||
tcs = new TaskCompletionSource<IReadOnlyList<T>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
_countWaiters.Add(tcs);
|
||
}
|
||
}
|
||
}
|
||
}
|