perf(worker): signal-driven event drain — removes the 25 ms latency floor and idle wakeups
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
@@ -300,6 +302,123 @@ public sealed class MxAccessEventQueueTests
|
||||
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, queue.Fault?.Category);
|
||||
}
|
||||
|
||||
// Wake-signal timings. The fallback is far longer than the patience deliberately: every wait
|
||||
// below asserts "the signal completed this", which is only a faithful claim while the fallback
|
||||
// timeout cannot have completed it within the patience window. The patience itself is generous
|
||||
// so a loaded CI box cannot fail a test that is not about latency.
|
||||
private static readonly TimeSpan WakeFallback = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan WakePatience = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the queue wakes a parked waiter as soon as an event is enqueued, rather than
|
||||
/// leaving it to time out. This is what removes the drain loop's latency floor: before the
|
||||
/// signal existed, an event arriving at an idle queue waited out the loop's whole poll tick.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForEventsAsync_EnqueueAfterIdle_CompletesWithoutWaitingTheFallback()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
|
||||
Task wait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
|
||||
Assert.False(wait.IsCompleted);
|
||||
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
|
||||
|
||||
Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience)));
|
||||
await wait;
|
||||
Assert.Equal(1, queue.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a fault recorded while the waiter is parked wakes it too. The drain loop
|
||||
/// discovers faults by calling <c>DrainFault()</c> at the top of each pass, so without this
|
||||
/// signal an overflow or conversion fault would not be reported until the loop's fallback
|
||||
/// tick expired.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForEventsAsync_RecordFaultWhileParked_WakesTheWaiter()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
|
||||
Task wait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
|
||||
Assert.False(wait.IsCompleted);
|
||||
|
||||
queue.RecordFault(new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
});
|
||||
|
||||
Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience)));
|
||||
await wait;
|
||||
Assert.NotNull(queue.DrainFault());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the one-permit cap loses no wakeups. A burst that lands while nobody is waiting
|
||||
/// leaves exactly one pending wake — the waiter that consumes it drains the whole burst, so
|
||||
/// coalescing costs nothing — and that consumed wake is not replayed: the next wait parks
|
||||
/// until a new enqueue signals it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForEventsAsync_BurstWhileNoWaiter_CoalescesToOneWakeThatLosesNothing()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 16);
|
||||
for (int itemHandle = 0; itemHandle < 5; itemHandle++)
|
||||
{
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle));
|
||||
}
|
||||
|
||||
Task firstWait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
|
||||
Assert.Same(firstWait, await Task.WhenAny(firstWait, Task.Delay(WakePatience)));
|
||||
await firstWait;
|
||||
|
||||
// One wake, the whole burst: the waiter re-drains everything queued, which is why capping
|
||||
// the signal at a single permit cannot drop an event.
|
||||
Assert.Equal(5, queue.Drain(maxEvents: 0).Count);
|
||||
|
||||
Task secondWait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
|
||||
Assert.False(secondWait.IsCompleted);
|
||||
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 99));
|
||||
|
||||
Assert.Same(secondWait, await Task.WhenAny(secondWait, Task.Delay(WakePatience)));
|
||||
await secondWait;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the timeout still bounds an unsignalled wait: the fallback survives as a ceiling
|
||||
/// on how long a caller may sleep, so a state change reached by some future path that does
|
||||
/// not signal is still observed on the next pass rather than never.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForEventsAsync_WithNoSignal_CompletesAtTheFallbackTimeout()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
|
||||
Task wait = queue.WaitForEventsAsync(TimeSpan.FromMilliseconds(25), CancellationToken.None);
|
||||
|
||||
Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience)));
|
||||
await wait;
|
||||
}
|
||||
|
||||
/// <summary>Verifies a cancelled wait unwinds instead of hanging until the fallback expires.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForEventsAsync_WhenCancelled_Throws()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
using CancellationTokenSource cancellation = new();
|
||||
|
||||
Task wait = queue.WaitForEventsAsync(WakeFallback, cancellation.Token);
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await wait);
|
||||
}
|
||||
|
||||
// Mirrors MxAccessEventQueue's per-event repeated-field allowance. Kept local rather than made
|
||||
// public on the queue: the byte-budget tests state their budgets in units of that charge, so a
|
||||
// change to it should surface here as a failing bound instead of silently moving with the code.
|
||||
|
||||
Reference in New Issue
Block a user