Repository navigation
fix(outbox): enforce maxOutStandingMessages for an async-only outbox - #4552
Conversation
The two sync calls in OutstandingMessagesCheck differed only in the maxCount argument. Work out maxCount first and make a single call. With no limit the ceiling is the default that GetOutstandingMessageCount already used (100), so behaviour is unchanged.
OutstandingMessagesCheck only counted through the sync outbox, so a mediator configured with an async-only outbox never saw its outstanding messages and maxOutStandingMessages was never enforced. Count through the async outbox when there is no sync one. The check already runs on a background thread without a SynchronizationContext, so blocking on the async count cannot deadlock there. Add tests for an async-only outbox and for an outbox that supports both.
|
@claude Can we check this one, in particular this comment from the author: "Question: blocking on the async count is the smallest change. If you would rather make the check itself async, I can do that." This might be dangerous given that we have a single-threaded pump with our own SynchronizationContext and Scheduler. Blocking on the count might hurt us. Can you trace that? |
|
Claude finished @iancooper's task in 1m 2s —— View job Tracing the blocking async count against the pump's SynchronizationContext
Short answerThis can't deadlock the pump. The The trace
This matches the existing design: the sync-outbox branch already does blocking I/O on the same pool thread for the same reason ( Remaining costs (minor)
Should the check be async?Not needed for correctness. If you want it, it's a small change and fixes the pool-thread costs above at the same time: // CheckOutstandingMessages
_ = Task.Run(() => OutstandingMessagesCheckAsync(requestContext));
private async Task OutstandingMessagesCheckAsync(RequestContext? requestContext)
{
await s_checkOutstandingSemaphoreToken.WaitAsync().ConfigureAwait(false);
try
{
...
if (_outBox != null) { /* sync call as now */ return; }
if (_asyncOutbox != null)
{
_outStandingCount = await _asyncOutbox
.GetOutstandingMessageCountAsync(_maxOutStandingCheckInterval, requestContext, maxCount, args: _outBoxBag)
.ConfigureAwait(false);
return;
}
...
}
finally { s_checkOutstandingSemaphoreToken.Release(); }
}This still runs off the pump: Recommendation
I only read the code for this. I did not run the tests. |
|
This is worth fixing:
This one might be worth raising as its own issue, it may bite at scale
|
|
A heads-up from the investigation in #4560 and #4554. Adding Two possible ways to avoid adding to that:
Not a blocker for the async-only bug itself. It only affects async-only outboxes, and none in the repo are. |
|
Thanks, Ian. I'll push the extended comment on why that thread has no SynchronizationContext, with a small test that posts from inside the pump's context and checks the count doesn't deadlock it. On the blocking call, I'll go with your second option: I'll bring this PR up to date once the skip at -1 from #4554 has merged, so the async count only runs when a limit is set. I'll open an issue for the static semaphore and the missing timeout and cancellation on the count once the #4554 PRs are up, so it only covers what's left. |
@guilhermeappel tag me when it's done and I'll re-review |
…pump Run PostAsync inside BrighterAsyncContext.Run against an async-only outbox whose outstanding count yields before it answers, so the count's continuation would be posted back to the pump's SynchronizationContext if the check ran there. The test fails on its timeout when OutstandingMessagesCheck is called inline from CheckOutstandingMessages instead of through Task.Run. Make AsyncOnlyOutboxWrapper's count overridable so the test double can yield.
Task.Run uses TaskScheduler.Default and does not carry the caller's SynchronizationContext. That is what keeps the blocking call on the async count from deadlocking the message pump, so the check must not be called inline.
|
Thanks @guilhermeappel |
…-starvation' Picks up master (#4552, #4563) merged on GitHub. release_notes.md: kept the updated #4560 cost paragraph and master's #4541 section; ADR index regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpjnqGWbARr3Z3ofGc6C2x
I didn't add a release note in PR #4552. With an async-only outbox and a limit of 0 or more, PostAsync and DepositPostAsync can now throw OutboxLimitReachedException where they never did before, so add one.
Description
maxOutStandingMessagesis never enforced when the producer's outbox implements onlyIAmAnOutboxAsync.OutboxProducerMediator.OutstandingMessagesCheckcounted outstanding messages only through the sync outbox, so with an async-only outbox the count stayed at 0 andPostAsyncnever threwOutboxLimitReachedException.It now counts through the async outbox when there is no sync one.
OutstandingMessagesCheckis only called from theTask.RuninCheckOutstandingMessages, so it blocks there withGetAwaiter().GetResult()on a thread-pool thread with noSynchronizationContext.There are two commits, because CONTRIBUTING asks to keep structural and behavioural changes apart. The first merges the two sync calls, which differed only in
maxCount, into one call (no behaviour change). The second adds the async branch and the tests.Every outbox in this repo implements both interfaces, so none of them is affected. This matters for custom or third-party outboxes that are async-only. For those, a configured limit starts being enforced, so
PostAsynccan now throwOutboxLimitReachedExceptionwhere it never did before.I found this while looking at #4489. It is independent of it.
Related Issues
Related to #4489 (found while investigating; does not fix it).
Type of Change
Checklist
Additional Notes
OutstandingLimitAnyOutboxTestsposts until the limit is hit, once with an async-only outbox and once with an outbox that has both interfaces. I ran them on a clean checkout of master with only the new test files added: the async-only test fails onAssert.True(reachedLimit), the other passes. With this change both pass. The Core suite on net10.0 passes (1627 passed, 7 skipped).