fix(notification-outbox): keyset Pending back-fill + distinct ids so a mid-claim Retry cannot double-deliver (review 2026-10-21 06/PERF-5 follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 09:49:19 -04:00
parent 1627513fdb
commit 5f7f889a96
2 changed files with 94 additions and 2 deletions
@@ -632,6 +632,80 @@ public class NotificationOutboxRepositoryTests : IDisposable
due.Select(n => n.NotificationId).ToArray());
}
/// <summary>
/// Verifies that a row turning Pending BETWEEN the claim's queries (an operator Retry
/// flipping an older Parked row back to Pending) cannot shift the back-fill onto a row
/// already claimed: the batch never contains a duplicate id, so no notification is
/// delivered twice in one sweep (adversarial review of 06/PERF-5).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDueAsync_RowTurnsPendingMidClaim_NeverClaimsAnIdTwice()
{
var now = new DateTimeOffset(2026, 5, 19, 12, 0, 0, TimeSpan.Zero);
var retried = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
createdAt: now.AddHours(-5));
var pending = Enumerable.Range(0, 5)
.Select(i => MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
createdAt: now.AddMinutes(-10 + i)))
.ToList();
var flip = new FlipToPendingBeforeSecondRead(retried.NotificationId);
await using var ctx = SqliteTestHelper.CreateInMemoryContext(flip);
ctx.Notifications.Add(retried);
ctx.Notifications.AddRange(pending);
await ctx.SaveChangesAsync();
ctx.ChangeTracker.Clear();
flip.Arm();
var due = await new NotificationOutboxRepository(ctx).GetDueAsync(now, batchSize: 4);
var ids = due.Select(n => n.NotificationId).ToList();
Assert.Equal(ids.Count, ids.Distinct().Count());
Assert.Equal(
new[]
{
pending[0].NotificationId, pending[1].NotificationId,
pending[2].NotificationId, pending[3].NotificationId,
},
ids);
}
/// <summary>
/// Flips one notification to Pending just before the claim's SECOND read against the
/// Notifications table — the window an operator Retry can land in.
/// </summary>
private sealed class FlipToPendingBeforeSecondRead(string notificationId)
: Microsoft.EntityFrameworkCore.Diagnostics.DbCommandInterceptor
{
private int _reads = -1;
/// <summary>Starts counting reads from the next command on.</summary>
public void Arm() => _reads = 0;
/// <inheritdoc />
public override async ValueTask<Microsoft.EntityFrameworkCore.Diagnostics.InterceptionResult<System.Data.Common.DbDataReader>> ReaderExecutingAsync(
System.Data.Common.DbCommand command,
Microsoft.EntityFrameworkCore.Diagnostics.CommandEventData eventData,
Microsoft.EntityFrameworkCore.Diagnostics.InterceptionResult<System.Data.Common.DbDataReader> result,
CancellationToken cancellationToken = default)
{
if (_reads >= 0 && command.CommandText.Contains("Notifications", StringComparison.Ordinal)
&& ++_reads == 2)
{
await using var flip = command.Connection!.CreateCommand();
flip.CommandText = "UPDATE Notifications SET Status = 'Pending' WHERE NotificationId = $id;";
var p = flip.CreateParameter();
p.ParameterName = "$id";
p.Value = notificationId;
flip.Parameters.Add(p);
await flip.ExecuteNonQueryAsync(cancellationToken);
}
return await base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
/// <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]