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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user