Date: 2026-04-15
Accepted
Parent Requirement: specs/0026-timed-outbox-archiver-sync-fallback/requirements.md
Scope: This ADR addresses how TimedOutboxArchiver selects the sync or async archiving path based on outbox capabilities.
TimedOutboxArchiver is an IHostedService that periodically archives old messages from the outbox. It currently unconditionally calls OutboxArchiver.ArchiveAsync, which throws an ArgumentException when only a sync outbox is registered (issue #3670).
The codebase already has an established pattern for this problem. OutboxProducerMediator exposes HasAsyncOutbox() and HasOutbox() methods that check whether its internal _asyncOutbox / _outBox fields are non-null. Callers like CommandProcessor use these to choose the right code path. OutboxArchiver performs the same pattern-matching detection in its constructor but does not expose the result.
The forces at play:
- Outbox implementations may be sync-only, async-only, or both
TimedOutboxArchiverruns on aTimercallback, which is inherently sync but currently wraps an async call with.GetAwaiter().GetResult()- The fix should follow existing codebase conventions rather than inventing new patterns
Add HasAsyncOutbox() and HasOutbox() methods to OutboxArchiver, following the same naming convention used by OutboxProducerMediator. Then update TimedOutboxArchiver.Archive to check async availability and fall back to the sync path, with an explicit guard for the case where neither is available (FR3).
OutboxArchiver — knowing which outbox capabilities are available:
- Add
public bool HasAsyncOutbox()— returns_asyncOutbox != null - Add
public bool HasOutbox()— returns_outBox != null
These methods expose existing knowledge without adding new state. The names match OutboxProducerMediator.HasAsyncOutbox() and OutboxProducerMediator.HasOutbox() exactly.
TimedOutboxArchiver — deciding which archive path to invoke:
- In the
Archivemethod, check_archiver.HasAsyncOutbox()first (FR1) - If true: call
await _archiver.ArchiveAsync(...)(current behavior) - Else if
_archiver.HasOutbox(): call_archiver.Archive(...)viaTask.Run(FR2, sync fallback) - Else: log a warning that no outbox is configured and return (FR3)
Step 1 (structural): Add the two query methods to OutboxArchiver. No behavioral change — existing callers are unaffected.
Step 2 (behavioral): Update TimedOutboxArchiver.Archive to branch:
private async Task Archive(CancellationToken cancellationToken)
{
try
{
var lockId = await _distributedLock.ObtainLockAsync(LockingResourceName, cancellationToken);
if (lockId == null)
{
Log.OutboxArchiverIsStillRunningAbandoningAttempt(s_logger);
return;
}
Log.OutboxArchiverLookingForMessagesToArchive(s_logger);
try
{
if (_archiver.HasAsyncOutbox())
await _archiver.ArchiveAsync(_options.MinimumAge, new RequestContext(), cancellationToken);
else if (_archiver.HasOutbox())
await Task.Run(() => _archiver.Archive(_options.MinimumAge, new RequestContext()), cancellationToken);
else
Log.NoOutboxConfigured(s_logger);
}
finally
{
await _distributedLock.ReleaseLockAsync(LockingResourceName, lockId, cancellationToken);
}
}
catch (Exception e)
{
Log.ErrorWhileSweepingTheOutbox(s_logger, e);
}
finally
{
Log.OutboxSweeperSleeping(s_logger);
}
}When async is available, it is preferred (FR1). When only sync is available, the sync path is used via Task.Run (FR2). When neither is available, a warning is logged and the method returns without throwing (FR3). This preserves current behavior for all existing users.
- Sync-only outbox implementations work with
TimedOutboxArchiverwithout error - Follows the established
HasAsyncOutbox()/HasOutbox()naming convention fromOutboxProducerMediator - No breaking changes — async-capable outboxes behave identically to before
- Explicit handling of all three cases (async, sync-only, neither) — no silent failures
- Minimal code change (two one-line methods + one three-way branch)
- Fixes pre-existing duplicate log call in
TimedOutboxArchiver.Archive
OutboxArchivergains two new public methods, slightly increasing its API surface- The distributed lock still uses
ObtainLockAsync/ReleaseLockAsynceven in the sync path (acceptable since theTimercallback already wraps everything in.GetAwaiter().GetResult()) - The sync fallback is wrapped in
Task.Runto avoid blocking inline in the async method; this adds one thread pool schedule but keeps the async pipeline yielding correctly
- Risk: Sync
Archivecalled on a thread pool timer thread could block if the outbox operation is slow. Mitigation: This is the same behavior as the current.GetAwaiter().GetResult()wrapping of the async path — no regression. Users with sync-only outboxes accept blocking semantics.
-
Add an
ArchiveAsyncoverload that internally falls back to sync — Rejected because it hides the sync/async decision insideOutboxArchiver, mixing concerns. The caller (TimedOutboxArchiver) is the coordinator and should decide. -
Require all outboxes to implement async — Rejected because it would be a breaking change and the framework explicitly supports sync-only outboxes through
IAmAnOutboxSync. -
Add a property instead of methods —
OutboxProducerMediatoruses methods, so we follow the same convention for consistency.
- Requirements: specs/0026-timed-outbox-archiver-sync-fallback/requirements.md
- GitHub Issue: #3670
- Existing pattern:
OutboxProducerMediator.HasAsyncOutbox()/HasOutbox()insrc/Paramore.Brighter/OutboxProducerMediator.cs