Skip to content

Commit e3adee4

Browse files
iammukeshmclaude
andcommitted
fix(eventing): make inbox idempotent, use no-op mail in integration tests
Root cause: all 68 integration tests pass, but the process hangs during shutdown because: 1. EfCoreInboxStore.MarkProcessedAsync did a blind INSERT, causing PK_InboxMessages duplicate key violations when the same event was processed via both the direct publish and outbox retry paths concurrently. This caused outbox messages to never be marked as processed, leading to infinite retry loops. 2. UserRegisteredEmailHandler tried to send real emails in CI (no SMTP configured), failing with "Rate limited". Hangfire retried these jobs with exponential backoff (10 retries), keeping the process alive past the CI timeout. Fixes: - Make MarkProcessedAsync idempotent: check HasProcessedAsync before INSERT, and catch DbUpdateException for the concurrent race case - Register a NoOpMailService in the test factory to prevent real SMTP calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f8a4c39 commit e3adee4

3 files changed

Lines changed: 40 additions & 2 deletions

File tree

src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ public async Task<bool> HasProcessedAsync(Guid eventId, string handlerName, Canc
2727

2828
public async Task MarkProcessedAsync(Guid eventId, string handlerName, string? tenantId, string eventType, CancellationToken ct = default)
2929
{
30+
// Idempotent: skip if already marked (race between direct publish and outbox retry)
31+
bool alreadyProcessed = await _dbContext.Set<InboxMessage>()
32+
.AnyAsync(i => i.Id == eventId && i.HandlerName == handlerName, ct)
33+
.ConfigureAwait(false);
34+
35+
if (alreadyProcessed)
36+
{
37+
return;
38+
}
39+
3040
var message = new InboxMessage
3141
{
3242
Id = eventId,
@@ -36,7 +46,16 @@ public async Task MarkProcessedAsync(Guid eventId, string handlerName, string? t
3646
ProcessedOnUtc = _timeProvider.GetUtcNow().UtcDateTime
3747
};
3848

39-
await _dbContext.Set<InboxMessage>().AddAsync(message, ct).ConfigureAwait(false);
40-
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
49+
_dbContext.Set<InboxMessage>().Add(message);
50+
51+
try
52+
{
53+
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
54+
}
55+
catch (DbUpdateException) when (!ct.IsCancellationRequested)
56+
{
57+
// Concurrent insert won the race — treat as already processed.
58+
_dbContext.ChangeTracker.Clear();
59+
}
4160
}
4261
}

src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using Finbuckle.MultiTenant;
33
using Finbuckle.MultiTenant.Abstractions;
44
using FSH.Framework.Jobs.Services;
5+
using FSH.Framework.Mailing;
6+
using FSH.Framework.Mailing.Services;
57
using FSH.Framework.Persistence;
68
using FSH.Framework.Shared.Multitenancy;
79
using FSH.Framework.Web.Modules;
@@ -101,6 +103,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
101103
JwtBearerDefaults.AuthenticationScheme,
102104
options => options.RequireHttpsMetadata = false);
103105

106+
// Replace real mail service with a no-op to avoid SMTP errors and Hangfire retries
107+
services.RemoveAll<IMailService>();
108+
services.AddSingleton<IMailService, NoOpMailService>();
109+
104110
// Detailed errors in tests instead of generic "An unexpected error occurred"
105111
var existingHandlers = services.Where(d =>
106112
d.ServiceType == typeof(Microsoft.AspNetCore.Diagnostics.IExceptionHandler)).ToList();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using FSH.Framework.Mailing;
2+
using FSH.Framework.Mailing.Services;
3+
4+
namespace Integration.Tests.Infrastructure;
5+
6+
/// <summary>
7+
/// No-op mail service for integration tests ��� prevents real SMTP calls
8+
/// and avoids Hangfire retry loops from email failures.
9+
/// </summary>
10+
internal sealed class NoOpMailService : IMailService
11+
{
12+
public Task SendAsync(MailRequest request, CancellationToken ct) => Task.CompletedTask;
13+
}

0 commit comments

Comments
 (0)