fix(store-and-forward): resolve StoreAndForward-003, re-triage 002 — fix retry-count off-by-one

This commit is contained in:
Joseph Doherty
2026-05-16 19:57:28 -04:00
parent 09b4bd5dfa
commit 71c0564ec0
4 changed files with 101 additions and 14 deletions

View File

@@ -86,7 +86,9 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
Assert.Equal(1, msg.RetryCount);
// StoreAndForward-003: RetryCount counts sweep retries only; the immediate
// attempt is attempt 0, so a freshly buffered message has RetryCount 0.
Assert.Equal(0, msg.RetryCount);
}
[Fact]
@@ -134,6 +136,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 2);
// StoreAndForward-003: MaxRetries bounds sweep retries (not the immediate
// attempt), so a message with MaxRetries=2 needs two retry sweeps to park.
await _service.RetryPendingMessagesAsync();
var afterFirst = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, afterFirst!.Status);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
@@ -141,6 +149,34 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
// ── StoreAndForward-003: retry-count accounting ──
[Fact]
public async Task RetryPendingMessagesAsync_MaxRetriesOne_PerformsExactlyOneRetryBeforeParking()
{
// The immediate attempt is attempt 0; MaxRetries=1 must allow exactly one
// retry sweep before parking. The pre-fix off-by-one parked with zero retries.
var attempts = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => { Interlocked.Increment(ref attempts); throw new HttpRequestException("always fails"); });
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 1);
// After the immediate failed attempt the message is buffered, not parked.
var buffered = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, buffered!.Status);
Assert.Equal(1, attempts); // only the immediate attempt so far
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
Assert.Equal(2, attempts); // immediate attempt + exactly one retry
Assert.Equal(1, msg.RetryCount); // one sweep retry recorded
}
[Fact]
public async Task RetryPendingMessagesAsync_PermanentFailureOnRetry_ParksMessage()
{
@@ -332,6 +368,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
Assert.Equal(1, msg.RetryCount); // counts as the caller's first attempt
// StoreAndForward-003: the caller's own attempt is attempt 0; RetryCount
// counts only sweep retries, so a freshly buffered message has RetryCount 0.
Assert.Equal(0, msg.RetryCount);
}
}