-
-
Notifications
You must be signed in to change notification settings - Fork 97
Advance filtered AllStreamSubscription checkpoints on server checkpointReached #554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1cecb23
Fix filtered AllStreamSubscription checkpoint parking on unmatched ev…
alexeyzimarev 950c5f4
Address review: commit-queue backpressure + skip activity for payload…
alexeyzimarev 447fc88
Add regression tests for checkpoint gate safety and commit backpressure
alexeyzimarev b26ce79
Address review: explicit null guard before reflection in CommitDiagno…
alexeyzimarev 88b263d
Merge branch 'dev' into feature/all-stream-checkpoint-reached
alexeyzimarev b5e26cd
Merge branch 'dev' into feature/all-stream-checkpoint-reached
alexeyzimarev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
165 changes: 165 additions & 0 deletions
165
src/Core/test/Eventuous.Tests.Subscriptions/CheckpointCommitHandlerBackpressureTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| using System.Threading.Channels; | ||
| using Eventuous.Subscriptions.Checkpoints; | ||
| using Shouldly; | ||
|
|
||
| namespace Eventuous.Tests.Subscriptions; | ||
|
|
||
| /// <summary> | ||
| /// Pins the channel-backpressure behaviour <see cref="CheckpointCommitHandler"/> switched to | ||
| /// (instead of throwing) when the commit queue fills up while the checkpoint store is slow: a | ||
| /// dropped <see cref="CommitPosition"/> is poison — a gap in the sequence stalls checkpoint | ||
| /// progression permanently — so overflow must throttle the caller, not fail the commit. | ||
| /// </summary> | ||
| public class CheckpointCommitHandlerBackpressureTests { | ||
| static CommitPosition Pos(ulong sequence) => new(sequence, sequence, DateTime.UtcNow); | ||
|
|
||
| [Test] | ||
| [Timeout(20_000)] | ||
| public async Task Commit_awaits_capacity_instead_of_throwing_when_the_channel_is_full(CancellationToken cancellationToken) { | ||
| List<ulong> committed = []; | ||
| TaskCompletionSource storeGate = new(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| TaskCompletionSource storeEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); | ||
|
|
||
| async ValueTask<Checkpoint> CommitFn(Checkpoint checkpoint, bool force, CancellationToken ct) { | ||
| storeEntered.TrySetResult(); | ||
| await storeGate.Task.WaitAsync(ct); | ||
| lock (committed) committed.Add(checkpoint.Position!.Value); | ||
|
|
||
| return checkpoint; | ||
| } | ||
|
|
||
| await using var handler = new CheckpointCommitHandler("backpressure-sub", CommitFn, TimeSpan.FromMilliseconds(10), batchSize: 1); | ||
|
|
||
| // The gate must open no matter how the test body ends: a failed assertion with the gate | ||
| // still closed would leave the worker parked in CommitFn and the `await using` disposal | ||
| // hanging until the test timeout — masking the clean assertion failure. | ||
| try { | ||
| // Sequence 0 is picked up by the worker and blocks inside CommitFn on the stalled gate — | ||
| // this is the "current commit in flight" the rest of the batch queues behind. The entered | ||
| // signal proves the worker actually dequeued it before we start filling the channel. | ||
| await handler.Commit(Pos(0), cancellationToken); | ||
| await storeEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); | ||
|
|
||
| // Fill the bounded channel: capacity is batchSize * 1000 = 1000. | ||
| for (ulong sequence = 1; sequence <= 1000; sequence++) { | ||
| await handler.Commit(Pos(sequence), cancellationToken).AsTask().WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); | ||
| } | ||
|
|
||
| // The channel is now full and the worker is still blocked on sequence 0: the next Commit | ||
| // call has nowhere to enqueue to. It must not throw — it should await channel capacity. | ||
| var overflow = handler.Commit(Pos(1001), cancellationToken); | ||
| await Task.Delay(200, cancellationToken); | ||
| overflow.IsCompleted.ShouldBeFalse("Commit should be backpressured (awaiting channel capacity), not completed or throwing"); | ||
|
|
||
| // Let the store recover: the worker drains the backlog, one sequence at a time. | ||
| storeGate.TrySetResult(); | ||
|
|
||
| await overflow.AsTask().WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); | ||
|
|
||
| // Poll until the store has recorded sequence 1001, then assert nothing was skipped along | ||
| // the way: because the commit batch size is 1 and the run is fully contiguous, every single | ||
| // sequence from 0 to 1001 must have gone through CommitFn, in order. | ||
| var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); | ||
| List<ulong> snapshot; | ||
|
|
||
| while (true) { | ||
| lock (committed) snapshot = [..committed]; | ||
|
|
||
| if (snapshot.Count > 0 && snapshot[^1] == 1001) break; | ||
|
|
||
| if (DateTime.UtcNow > deadline) break; | ||
|
|
||
| await Task.Delay(50, cancellationToken); | ||
| } | ||
|
|
||
| snapshot.ShouldBe(Enumerable.Range(0, 1002).Select(i => (ulong)i).ToList()); | ||
| } finally { | ||
| storeGate.TrySetResult(); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| [Timeout(20_000)] | ||
| public async Task Dispose_releases_a_backpressured_commit_and_drains_without_hanging(CancellationToken cancellationToken) { | ||
| List<(ulong Position, bool Force)> committed = []; | ||
| TaskCompletionSource storeGate = new(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| TaskCompletionSource storeEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); | ||
|
|
||
| async ValueTask<Checkpoint> CommitFn(Checkpoint checkpoint, bool force, CancellationToken ct) { | ||
| storeEntered.TrySetResult(); | ||
| await storeGate.Task.WaitAsync(ct); | ||
| lock (committed) committed.Add((checkpoint.Position!.Value, force)); | ||
|
|
||
| return checkpoint; | ||
| } | ||
|
|
||
| var handler = new CheckpointCommitHandler("backpressure-dispose-sub", CommitFn, TimeSpan.FromMilliseconds(10), batchSize: 1); | ||
|
|
||
| Task? disposeTask = null; | ||
|
|
||
| // Two guarantees on every exit path: the gate opens (a failed assertion with the gate still | ||
| // closed would otherwise leave the worker parked in CommitFn and disposal hanging until the | ||
| // test timeout), and the handler's disposal is awaited (bounded) so a failure doesn't leak | ||
| // a live worker into the rest of the run. | ||
| try { | ||
| // Sequence 0 is dequeued by the worker and blocks in CommitFn on the still-stalled gate. | ||
| await handler.Commit(Pos(0), cancellationToken); | ||
| await storeEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); | ||
|
|
||
| // Fill the bounded channel (capacity batchSize * 1000 = 1000) and park one more Commit in | ||
| // backpressure — a writer genuinely awaiting channel capacity when Dispose begins. | ||
| for (ulong sequence = 1; sequence <= 1000; sequence++) { | ||
| await handler.Commit(Pos(sequence), cancellationToken).AsTask().WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); | ||
| } | ||
|
|
||
| var overflow = handler.Commit(Pos(1001), cancellationToken); | ||
| await Task.Delay(200, cancellationToken); | ||
| overflow.IsCompleted.ShouldBeFalse("The overflow Commit should be backpressured before Dispose begins"); | ||
|
|
||
| // Dispose while the store is stalled and a writer is parked on the full channel. Dispose | ||
| // completes the channel writer, which releases the parked write rather than leaving it | ||
| // hanging forever: the pending WriteAsync faults with ChannelClosedException (observed | ||
| // behaviour, pinned here). The position is lost, but only because the handler is shutting | ||
| // down — the caller is unblocked, not stalled. The outcome is captured first, then | ||
| // asserted, so a surprise here still flows through the finally-side cleanup. | ||
| disposeTask = handler.DisposeAsync().AsTask(); | ||
|
|
||
| var overflowOutcome = await overflow.AsTask() | ||
| .WaitAsync(TimeSpan.FromSeconds(5), cancellationToken) | ||
| .ContinueWith(t => t.Exception?.GetBaseException(), TaskContinuationOptions.ExecuteSynchronously); | ||
|
|
||
| overflowOutcome.ShouldBeOfType<ChannelClosedException>("Completing the writer should release the parked Commit with ChannelClosedException"); | ||
|
|
||
| // Let the store recover so dispose can drain the queued positions and run its final | ||
| // force-commit within its own internal bounds. | ||
| storeGate.TrySetResult(); | ||
|
|
||
| var completed = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)); | ||
| completed.ShouldBe(disposeTask, "DisposeAsync should complete once the store recovers, not hang"); | ||
| await disposeTask; | ||
|
|
||
| List<(ulong Position, bool Force)> snapshot; | ||
| lock (committed) snapshot = [..committed]; | ||
|
|
||
| snapshot.ShouldNotBeEmpty(); | ||
|
|
||
| // The queued positions (0..1000) drained normally, in order, during dispose; the parked | ||
| // overflow write (1001) never entered the channel, so it must not appear. | ||
| var normal = snapshot.Where(x => !x.Force).Select(x => x.Position).ToList(); | ||
| normal.ShouldBe(Enumerable.Range(0, 1001).Select(i => (ulong)i).ToList()); | ||
|
|
||
| // CheckpointCommitHandler's OnDispose force-recommits whatever it last successfully stored — | ||
| // it should match the highest position that drained normally, not something stale or ahead | ||
| // of it. | ||
| var forced = snapshot.Where(x => x.Force).ToList(); | ||
| forced.ShouldNotBeEmpty(); | ||
| forced[^1].Position.ShouldBe(normal[^1]); | ||
| } finally { | ||
| storeGate.TrySetResult(); | ||
|
|
||
| // Bounded so a genuinely hung disposal surfaces the original assertion failure instead | ||
| // of stalling the finally block until the test timeout. | ||
| await Task.WhenAny(disposeTask ?? handler.DisposeAsync().AsTask(), Task.Delay(TimeSpan.FromSeconds(10))); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.