perf(events): copy-on-write subscriber snapshot in fan-out pump
This commit is contained in:
@@ -1057,6 +1057,63 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.False(lateCts.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guards the copy-on-write fan-out snapshot: registrations and unregistrations churn on
|
||||
/// another thread while the pump is actively fanning events, and the stable subscriber
|
||||
/// must still receive every event exactly once and in order. The pump captures the
|
||||
/// subscriber array once per event instead of enumerating the dictionary, so a mutation
|
||||
/// racing the fan-out must never drop, duplicate, or reorder an event for a subscriber
|
||||
/// registered throughout — nor leave the array and the dictionary disagreeing on the
|
||||
/// subscriber count once the churn stops.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegistrationChurnDuringFanOut_StableSubscriberStillReceivesEveryEventInOrder()
|
||||
{
|
||||
// Below the 64-event per-subscriber queue capacity, so the stable subscriber cannot
|
||||
// overflow and be disconnected while the writes race the churn — the assertion stays
|
||||
// deterministic no matter how the threads interleave.
|
||||
const int EventCount = 50;
|
||||
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
await using SessionEventDistributor distributor = CreateDistributor(source.Reader);
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
using IEventSubscriberLease stable = distributor.Register();
|
||||
|
||||
using CancellationTokenSource churnCts = new();
|
||||
Task churn = Task.Run(async () =>
|
||||
{
|
||||
while (!churnCts.IsCancellationRequested)
|
||||
{
|
||||
// Register then immediately unregister: every iteration rebuilds the fan-out
|
||||
// snapshot twice, maximizing the chance of landing inside a fan-out pass.
|
||||
distributor.Register().Dispose();
|
||||
await Task.Yield();
|
||||
}
|
||||
});
|
||||
|
||||
for (ulong sequence = 1; sequence <= EventCount; sequence++)
|
||||
{
|
||||
source.Writer.TryWrite(Event(sequence));
|
||||
}
|
||||
|
||||
List<ulong> received = [];
|
||||
for (int i = 0; i < EventCount; i++)
|
||||
{
|
||||
received.Add((await ReadOneAsync(stable.Reader)).WorkerSequence);
|
||||
}
|
||||
|
||||
await churnCts.CancelAsync();
|
||||
await churn.WaitAsync(ReadTimeout);
|
||||
|
||||
Assert.Equal(Enumerable.Range(1, EventCount).Select(sequence => (ulong)sequence), received);
|
||||
|
||||
// Only the stable subscriber remains: the snapshot the count is read from tracked every
|
||||
// add and remove the churn performed.
|
||||
Assert.Equal(1, distributor.SubscriberCount);
|
||||
}
|
||||
|
||||
private static SessionEventDistributor CreateDistributor(ChannelReader<MxEvent> source)
|
||||
=> CreateDistributor(source, replayBufferCapacity: 1024, replayRetentionSeconds: 300);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user