@@ -113,6 +113,68 @@ await store.ProcessBatchAsync((data, _) =>
113113 dispatched . ShouldBe ( [ first , second ] ) ;
114114 }
115115
116+ [ Fact ]
117+ public async Task ThrottlesParallelDispatchToTheConfiguredMaxDegreeOfParallelism ( )
118+ {
119+ // Arrange
120+ await using var seed = NewContext ( ) ;
121+ for ( var i = 0 ; i < 6 ; i ++ )
122+ {
123+ seed . OutboxMessages . Add ( NewMessage ( Guid . CreateVersion7 ( ) , DateTimeOffset . UtcNow ) ) ;
124+ }
125+ await seed . SaveChangesAsync ( CancellationToken ) ;
126+ await using var context = NewContext ( ) ;
127+ var store = NewStore ( context , maxRetries : 3 , enableParallelPublishing : true , maxDegreeOfParallelism : 2 ) ;
128+ var dispatcher = new ConcurrencyTrackingDispatcher ( saturationCount : 2 ) ;
129+
130+ // Act
131+ var processTask = store . ProcessBatchAsync ( ( _ , token ) => dispatcher . DispatchAsync ( token ) , CancellationToken ) ;
132+ await dispatcher . SaturationReached . WaitAsync ( TimeSpan . FromSeconds ( 30 ) , CancellationToken ) ;
133+ dispatcher . Release ( ) ;
134+ var processed = await processTask ;
135+
136+ // Assert
137+ processed . ShouldBe ( 6 ) ;
138+ dispatcher . PeakConcurrency . ShouldBe ( 2 ) ;
139+ }
140+
141+ [ Fact ]
142+ public async Task RecordsEachMessageOutcomeIndividuallyWhenAParallelBatchHasAFailure ( )
143+ {
144+ // Arrange
145+ var failing = new Guid ( "00000000-0000-0000-0000-000000000001" ) ;
146+ await using var seed = NewContext ( ) ;
147+ seed . OutboxMessages . Add ( NewMessage ( failing , DateTimeOffset . UtcNow ) ) ;
148+ seed . OutboxMessages . Add ( NewMessage ( new Guid ( "00000000-0000-0000-0000-000000000002" ) , DateTimeOffset . UtcNow ) ) ;
149+ seed . OutboxMessages . Add ( NewMessage ( new Guid ( "00000000-0000-0000-0000-000000000003" ) , DateTimeOffset . UtcNow ) ) ;
150+ await seed . SaveChangesAsync ( CancellationToken ) ;
151+ await using var context = NewContext ( ) ;
152+ var store = NewStore ( context , maxRetries : 3 , enableParallelPublishing : true ) ;
153+
154+ // Act
155+ var processed = await store . ProcessBatchAsync (
156+ ( data , _ ) => Task . FromResult < string ? > ( data . Id == failing ? "boom" : null ) ,
157+ CancellationToken ) ;
158+
159+ // Assert
160+ processed . ShouldBe ( 2 ) ;
161+ await using var verify = NewContext ( ) ;
162+ var failed = await verify . OutboxMessages . SingleAsync ( message => message . Id == failing , CancellationToken ) ;
163+ failed . ProcessedOnUtc . ShouldBeNull ( ) ;
164+ failed . FailedOnUtc . ShouldBeNull ( ) ;
165+ failed . RetryCount . ShouldBe ( 1 ) ;
166+ failed . Error . ShouldBe ( "boom" ) ;
167+ var succeeded = await verify . OutboxMessages . Where ( message => message . Id != failing ) . ToListAsync ( CancellationToken ) ;
168+ succeeded . Count . ShouldBe ( 2 ) ;
169+ foreach ( var message in succeeded )
170+ {
171+ message . ProcessedOnUtc . ShouldNotBeNull ( ) ;
172+ message . FailedOnUtc . ShouldBeNull ( ) ;
173+ message . RetryCount . ShouldBe ( 0 ) ;
174+ message . Error . ShouldBeNull ( ) ;
175+ }
176+ }
177+
116178 [ Fact ]
117179 public async Task DeleteProcessedRemovesOldTerminalRowsButKeepsRecentAndPending ( )
118180 {
@@ -139,8 +201,13 @@ public async Task DeleteProcessedRemovesOldTerminalRowsButKeepsRecentAndPending(
139201 remaining . ShouldContain ( message => message . ProcessedOnUtc >= now . AddDays ( - 7 ) ) ;
140202 }
141203
142- private static EntityFrameworkOutboxStore < TestDbContext > NewStore ( TestDbContext context , int maxRetries ) =>
143- new ( context , TimeProvider . System , Options . Create ( new OutboxProcessingOptions { MaxRetries = maxRetries } ) ) ;
204+ private static EntityFrameworkOutboxStore < TestDbContext > NewStore ( TestDbContext context , int maxRetries , bool enableParallelPublishing = false , int maxDegreeOfParallelism = 4 ) =>
205+ new ( context , TimeProvider . System , Options . Create ( new OutboxProcessingOptions
206+ {
207+ MaxRetries = maxRetries ,
208+ EnableParallelPublishing = enableParallelPublishing ,
209+ MaxDegreeOfParallelism = maxDegreeOfParallelism ,
210+ } ) ) ;
144211
145212 private static OutboxMessage NewMessage ( Guid id , DateTimeOffset occurredOn , DateTimeOffset ? failedOnUtc = null , DateTimeOffset ? processedOnUtc = null ) => new ( )
146213 {
@@ -155,6 +222,53 @@ private static EntityFrameworkOutboxStore<TestDbContext> NewStore(TestDbContext
155222
156223 private TestDbContext NewContext ( ) => new ( new DbContextOptionsBuilder < TestDbContext > ( ) . UseSqlite ( _connection ) . Options ) ;
157224
225+ private sealed class ConcurrencyTrackingDispatcher ( int saturationCount )
226+ {
227+ private readonly TaskCompletionSource _saturated = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
228+ private readonly TaskCompletionSource _gate = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
229+ private int _inFlight ;
230+ private int _peak ;
231+ private int _arrivals ;
232+
233+ public Task SaturationReached => _saturated . Task ;
234+
235+ public int PeakConcurrency => Volatile . Read ( ref _peak ) ;
236+
237+ public async Task < string ? > DispatchAsync ( CancellationToken cancellationToken )
238+ {
239+ RecordArrival ( ) ;
240+ await _gate . Task . WaitAsync ( cancellationToken ) ;
241+ Interlocked . Decrement ( ref _inFlight ) ;
242+ return null ;
243+ }
244+
245+ public void Release ( ) => _gate . TrySetResult ( ) ;
246+
247+ private void RecordArrival ( )
248+ {
249+ UpdatePeak ( Interlocked . Increment ( ref _inFlight ) ) ;
250+ if ( Interlocked . Increment ( ref _arrivals ) >= saturationCount )
251+ {
252+ _saturated . TrySetResult ( ) ;
253+ }
254+ }
255+
256+ private void UpdatePeak ( int current )
257+ {
258+ var seen = Volatile . Read ( ref _peak ) ;
259+ while ( current > seen )
260+ {
261+ var previous = Interlocked . CompareExchange ( ref _peak , current , seen ) ;
262+ if ( previous == seen )
263+ {
264+ return ;
265+ }
266+
267+ seen = previous ;
268+ }
269+ }
270+ }
271+
158272 public sealed class TestDbContext ( DbContextOptions < TestDbContext > options ) : DbContext ( options ) , ISaveOutboxMessages
159273 {
160274 public DbSet < OutboxMessage > OutboxMessages => Set < OutboxMessage > ( ) ;
0 commit comments