feat(store-and-forward): deferToSweep enqueue mode with immediate sweep kick for latency-sensitive callers

This commit is contained in:
Joseph Doherty
2026-07-08 20:46:08 -04:00
parent 283ce0647c
commit 05a71a0260
2 changed files with 109 additions and 1 deletions
@@ -451,6 +451,14 @@ public class StoreAndForwardService
/// When <c>false</c>, the caller has already made its own delivery attempt and the
/// message is buffered directly for the retry sweep (the handler is not invoked here).
/// </param>
/// <param name="deferToSweep">
/// When <c>true</c>, the handler is never invoked inline — the message is buffered
/// due-immediately (<see cref="StoreAndForwardMessage.LastAttemptAt"/> stays null)
/// and a background sweep is kicked. Use for callers on latency-sensitive threads
/// (e.g. script dispatchers) whose delivery involves a long Ask timeout. Unlike
/// <paramref name="attemptImmediateDelivery"/><c>: false</c>, no delivery attempt is
/// presumed to have already been made, so the row is due on the very next sweep.
/// </param>
/// <param name="messageId">
/// An explicit, caller-supplied message id. <c>null</c> (the default) makes the
/// service mint a fresh GUID. The Notification Outbox enqueue path supplies its own
@@ -487,6 +495,7 @@ public class StoreAndForwardService
int? maxRetries = null,
TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true,
bool deferToSweep = false,
string? messageId = null,
Guid? executionId = null,
string? sourceScript = null,
@@ -509,6 +518,18 @@ public class StoreAndForwardService
ParentExecutionId = parentExecutionId
};
// Latency-sensitive caller: never run the handler inline. Buffer
// due-immediately (LastAttemptAt intentionally left null so the row is due
// on the very next sweep, not held back by RetryInterval) and kick a
// background sweep so the healthy-path latency is milliseconds.
if (deferToSweep)
{
await BufferAsync(message);
RaiseActivity("Queued", category, $"Deferred to sweep: {target}");
TriggerSweep();
return new StoreAndForwardResult(true, message.Id, true);
}
// Attempt immediate delivery — unless the caller has already made a
// delivery attempt of its own (attemptImmediateDelivery: false). In that
// case re-invoking the handler here would dispatch the request twice.
@@ -571,6 +592,18 @@ public class StoreAndForwardService
Interlocked.Increment(ref _bufferedCount);
}
/// <summary>
/// Kicks a background retry sweep now (fire-and-forget). No-op before
/// <see cref="StartAsync"/> (storage may be uninitialized and the timer, whose
/// presence gates this, is not yet set). Overlap-safe: the sweep's
/// <see cref="_retryInProgress"/> CAS makes a concurrent kick a cheap no-op.
/// </summary>
public void TriggerSweep()
{
if (_retryTimer == null) return;
Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync());
}
/// <summary>
/// Background retry sweep. Processes all pending messages that are due for retry.
/// </summary>