Skip to content

Commit f340a3b

Browse files
authored
test: cover EntityFrameworkOutboxStore parallel publishing throttle and per-message outcome recording (#346)
1 parent 1c937cd commit f340a3b

1 file changed

Lines changed: 116 additions & 2 deletions

File tree

tests/Vulthil.SharedKernel.Outbox.EntityFrameworkCore.Tests/EntityFrameworkOutboxStoreTests.cs

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)