fix(email): reconcile abandoned sends
Age stale pre-delivery attempts to failed and in-delivery attempts to uncertain without provider I/O. Notify each owner with content-free guidance and keep recovery idempotent across replicas and restarts.
This commit is contained in:
@@ -168,6 +168,7 @@ builder.Services.AddHostedService<DailyExportHostedService>();
|
||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||
builder.Services.AddHostedService<AiOperationHostedService>();
|
||||
builder.Services.AddHostedService<EmailSendAttemptRecoveryHostedService>();
|
||||
|
||||
builder.Services.AddHttpClient("jobimport")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class EmailSendAttemptRecoveryHostedService(
|
||||
IServiceScopeFactory scopes,
|
||||
IStartupReadiness startupReadiness,
|
||||
ILogger<EmailSendAttemptRecoveryHostedService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
var result = await scope.ServiceProvider.GetRequiredService<EmailSendAttemptStore>()
|
||||
.ReconcileAbandonedAsync(stoppingToken);
|
||||
if (result.FailedPending > 0 || result.UncertainSending > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Recovered abandoned email attempts: failedPending={FailedPending}, uncertainSending={UncertainSending}. No provider retry was attempted.",
|
||||
result.FailedPending, result.UncertainSending);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Email send-attempt recovery failed; provider delivery was not attempted.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@ namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record CreateEmailSendAttempt(int JobApplicationId, string Provider, string ClientRequestId, string PayloadHash);
|
||||
public sealed record EmailSendAttemptCreation(EmailSendAttempt Attempt, bool Created);
|
||||
public sealed record EmailSendAttemptRecoveryResult(int FailedPending, int UncertainSending);
|
||||
|
||||
public sealed class EmailSendConflictException(string message) : InvalidOperationException(message);
|
||||
|
||||
public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider timeProvider)
|
||||
{
|
||||
public static readonly TimeSpan AbandonedAge = TimeSpan.FromMinutes(15);
|
||||
private DateTime UtcNow => timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
public Task<EmailSendAttempt?> GetAsync(Guid id, CancellationToken cancellationToken)
|
||||
@@ -74,6 +76,58 @@ public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider tim
|
||||
public Task<int> MarkUncertainAsync(Guid id, string failureCategory, CancellationToken cancellationToken) =>
|
||||
CompleteAsync(id, EmailSendStatuses.Uncertain, null, failureCategory, cancellationToken);
|
||||
|
||||
public async Task<EmailSendAttemptRecoveryResult> ReconcileAbandonedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = UtcNow;
|
||||
var cutoff = now - AbandonedAge;
|
||||
var candidates = await db.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item =>
|
||||
(item.Status == EmailSendStatuses.Pending && item.CreatedAtUtc <= cutoff) ||
|
||||
(item.Status == EmailSendStatuses.Sending && (item.StartedAtUtc ?? item.CreatedAtUtc) <= cutoff))
|
||||
.Select(item => new { item.Id, item.OwnerUserId, item.Status })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (candidates.Count == 0) return new EmailSendAttemptRecoveryResult(0, 0);
|
||||
|
||||
await using var transaction = db.Database.IsRelational()
|
||||
? await db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var failedPending = 0;
|
||||
var uncertainSending = 0;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var failed = candidate.Status == EmailSendStatuses.Pending;
|
||||
var terminalStatus = failed ? EmailSendStatuses.Failed : EmailSendStatuses.Uncertain;
|
||||
var failureCategory = failed ? "process_stopped_before_delivery" : "process_interrupted";
|
||||
var affected = await db.EmailSendAttempts.IgnoreQueryFilters()
|
||||
.Where(item => item.Id == candidate.Id && item.Status == candidate.Status &&
|
||||
(failed ? item.CreatedAtUtc <= cutoff : (item.StartedAtUtc ?? item.CreatedAtUtc) <= cutoff))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Status, terminalStatus)
|
||||
.SetProperty(item => item.FailureCategory, failureCategory)
|
||||
.SetProperty(item => item.CompletedAtUtc, now), cancellationToken);
|
||||
if (affected != 1) continue;
|
||||
|
||||
if (failed) failedPending++;
|
||||
else uncertainSending++;
|
||||
db.UserNotifications.Add(new UserNotification
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = candidate.OwnerUserId,
|
||||
Kind = failed ? "email.send.stopped" : "email.send.uncertain",
|
||||
Title = failed ? "Email send stopped" : "Check email delivery",
|
||||
Message = failed
|
||||
? "An email attempt stopped before provider delivery. Review the draft before trying again."
|
||||
: "Email delivery could not be confirmed after an interruption. Check the provider Sent folder and do not resend automatically.",
|
||||
LinkPath = "/correspondence",
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return new EmailSendAttemptRecoveryResult(failedPending, uncertainSending);
|
||||
}
|
||||
|
||||
private Task<int> CompleteAsync(Guid id, string status, string? providerMessageId, string? failureCategory, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureOwnerScope();
|
||||
|
||||
Reference in New Issue
Block a user