fix(notification-outbox): per-attempt timestamps, Pending/Retrying split claim + sweep budget, SMTP MaxConcurrentConnections clamp (review 2026-10-21 06/COR-1, 06/PERF-5, 06/IMPL-2)

This commit is contained in:
Joseph Doherty
2026-10-22 09:22:10 -04:00
parent ebf4b96789
commit 811a561885
10 changed files with 547 additions and 37 deletions
@@ -567,6 +567,71 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Equal(retryingDue.NotificationId, capped[1].NotificationId);
}
/// <summary>
/// Verifies that a retrying backlog older than every fresh Pending row cannot monopolise
/// the claim: the batch is split between the two statuses, each oldest-first
/// (review 2026-10-21 06/PERF-5).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDueAsync_RetryingBacklog_SharesBatchWithPending()
{
var now = new DateTimeOffset(2026, 5, 19, 12, 0, 0, TimeSpan.Zero);
var retrying = Enumerable.Range(0, 4)
.Select(i => MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
createdAt: now.AddHours(-10).AddMinutes(i), nextAttemptAt: now.AddMinutes(-1)))
.ToList();
var pending = Enumerable.Range(0, 4)
.Select(i => MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
createdAt: now.AddMinutes(-10 + i)))
.ToList();
_context.Notifications.AddRange(retrying);
_context.Notifications.AddRange(pending);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var due = await _repository.GetDueAsync(now, batchSize: 4);
Assert.Equal(
new[]
{
retrying[0].NotificationId, retrying[1].NotificationId,
pending[0].NotificationId, pending[1].NotificationId,
},
due.Select(n => n.NotificationId).ToArray());
}
/// <summary>
/// Verifies that an unused share of the batch is back-filled from the other status, so a
/// sweep with a short Retrying (or Pending) set still claims a full batch.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDueAsync_ShortRetryingSet_BackfillsFromPending()
{
var now = new DateTimeOffset(2026, 5, 19, 12, 0, 0, TimeSpan.Zero);
var retrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
createdAt: now.AddHours(-10), nextAttemptAt: now.AddMinutes(-1));
var pending = Enumerable.Range(0, 5)
.Select(i => MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
createdAt: now.AddMinutes(-10 + i)))
.ToList();
_context.Notifications.Add(retrying);
_context.Notifications.AddRange(pending);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var due = await _repository.GetDueAsync(now, batchSize: 4);
Assert.Equal(
new[]
{
retrying.NotificationId,
pending[0].NotificationId, pending[1].NotificationId, pending[2].NotificationId,
},
due.Select(n => n.NotificationId).ToArray());
}
/// <summary>Verifies that get by id returns the row when present and null otherwise.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]