fix(routing): never re-send a delivery the callee is still holding (#1172) - #4973
Conversation
…1172) Orleans' ResponseTimeout is a caller-side give-up timer, not a cancellation: when it fires the request has already been handed to the target activation and stays in its work queue. The router's delivery retry gated on IsTransientFailure, which matches TimeoutException — so a merely SLOW destination was sent the same delivery up to seven times. Nothing on the receive path can recognise a repeat (DeliverMessage ends in an unconditional hub.DeliverMessage(delivery); no reader of IMessageDelivery.Id dedupes), so that is duplicate handler execution, not a retry. The budget that licensed the generosity is bounded in ATTEMPTS, and its delays (250 ms -> 3 s) are sized for a rejection, which Orleans returns instantly. A timed-out attempt costs the transport's whole ResponseTimeout instead, so the same ladder means ~10 s for a rejection and ~3 m 40 s for a timeout, with one RoutingGrain dispatch slot held throughout. And the coupling runs the wrong way: a response timeout happens precisely when the destination is slow (a per-node hub whose HubReady has not emitted - for an _Activity/compile address, an in-mesh NodeType compile) or the silo is CPU starved, so the retry multiplied the work of the thing that was already too slow, at the moment it had least capacity. Adds a third predicate rather than narrowing the existing one, because the three questions are genuinely different and strictly nested: IsTransientFailure is another attempt conceivable (unchanged) IsResendableDeliveryFailure may we SEND THIS REQUEST again (new gate) ClassifyDeliveryException should the SENDER keep recovering (unchanged) IsResendableDeliveryFailure = !IsResponseTimeout && IsTransientFailure, walking the exception GRAPH (ExceptionChain) rather than the InnerException line, and gates both non-idempotent retry sites: RoutingGrain.DeliverToGrainObservable (serving IMessageHubGrain.DeliverMessage and IPodHubGrain.Deliver) and OrleansRoutingService.DispatchObservable (IRoutingGrain.RouteMessage, whose callee queue is the one that reached 541 deep in prod). IsTransientFailure is deliberately left intact: its other caller, AttachWithBoundedRetry's IPodHubGrain.Attach claim, IS idempotent, so re-sending it after a timeout costs nothing and is how the claim converges (#2633). Narrowing in place would have silently disarmed that retry. Declining to re-send suppresses nothing. The fault reaches the same arm it reached after the retries were exhausted, and ClassifyDeliveryException already answers a bare TimeoutException with the terminal ErrorType.Failed. The sender gets the identical verdict, one ResponseTimeout after the first attempt instead of seven of them later, with one copy of the delivery at the callee instead of seven. Negative control: TimedOutDeliveryIsNotResentTest. With the two gates reverted to IsTransientFailure and everything else unchanged, ATimedOutDelivery_IsSentExactlyOnce and ATimeoutCarriedInsideAnAggregate_IsAlsoNotResent(timeoutFirst: True) both fail "Expected value to be 1 ... but found 7"; 4 of 6 stay green (they pin the ladder and the rejection path). With the fix, 6 of 6 pass, and 68 of 68 neighbouring routing/classification tests pass unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The change is narrowly scoped to retry gating, is backed by deterministic tests that fail on the regression and pass with the fix, and does not introduce async/await into production routing code paths.
Review effort: Lite
Findings: None
What changed in this PR
This PR fixes a routing back-pressure amplifier by ensuring Orleans delivery retries do not re-send a delivery after a response timeout (where the callee may still be holding/processing the original request), while keeping bounded retries for genuinely resendable/transient failures (e.g., Orleans rejections).
Changes:
- Gate non-idempotent delivery retries on a new “resendable” predicate (
IsResendableDeliveryFailure) that excludes response timeouts (walks the exception graph viaExceptionChain). - Add a deterministic unit test suite proving: timeouts are sent exactly once; rejections still spend the retry budget; aggregate exception ordering doesn’t change the decision.
- Add an architecture doc page explaining the timeout vs rejection distinction and the three-predicate “ladder”, and link it into the architecture index.
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Orleans.Test/TimedOutDeliveryIsNotResentTest.cs | Adds regression + control tests covering timeout non-resend, rejection resend, and aggregate exception ordering. |
| src/MeshWeaver.Hosting.Orleans/RoutingGrain.cs | Switches DeliverToGrainObservable retry gate from IsTransientFailure to IsResendableDeliveryFailure; adds the new predicate. |
| src/MeshWeaver.Connection.Orleans/OrleansRoutingService.cs | Switches RouteMessage retry gate to IsResendableDeliveryFailure; introduces IsResponseTimeout (exception-graph walk) and the resendable predicate. |
| src/MeshWeaver.Documentation/Data/Architecture/ATimedOutDeliveryIsStillHeldByTheCallee.md | New architecture note documenting the mechanism and the predicate ladder. |
| src/MeshWeaver.Documentation/Data/Architecture.md | Adds the new architecture note to the Architecture topic map. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 4)0 tests 0 ✅ 0s ⏱️ Results for commit 524d126. |
Test Results (shard 1)0 tests 0 ✅ 0s ⏱️ Results for commit 524d126. |
Test Results (shard 3)0 tests 0 ✅ 0s ⏱️ Results for commit 524d126. |
Test Results (shard 5)0 tests 0 ✅ 0s ⏱️ Results for commit 524d126. |
Test Results (shard 2)601 tests 601 ✅ 27s ⏱️ Results for commit 524d126. |
Test Results (shard 0) 1 files 1 suites 1m 57s ⏱️ Results for commit 524d126. |
Test Results 2 files 2 suites 2m 24s ⏱️ Results for commit 524d126. |
Fixes #1172
Is #1172 the cluster's root? It is the cluster's REPORTER, and this is the third root behind it
feature:routing-back-pressureis one log site, not one defect. All 7 open issues are the sameRoutingGraincritical; the fingerprint folds in the activation id and episode number, which is whyone saturation event on 2026-09-19 fanned out into six separate tickets (#4908's own body says so).
#1172 is simply the oldest instance, filed 2026-08-10.
Across the 10 log samples recorded in the 7 issues, every single one reads
64 route dispatches in flightandrouting pool subscribing 0— 64 becauseReportSaturationfires on the one incrementthat crosses the threshold and then latches, so 64 is the only value it can print;
0because thepool's in-flight counter spans only
source.Subscribe, which for a route leg returns as soon as thegrain call is initiated. Neither number carries information. The number that does is
deepest:destinations 0, deepest 0destinations 1, deepest 62on onecache/…fcf31578#14,67341d12#7)This PR fixes the mechanism that put up to 7× more legs into that budget than the traffic justified,
and held each one ~7× longer. It is the third root named at this site: #1341 was O(node-size) JSON
patch construction on hub turns, #1358 was a genuine slot LEAK (
SubscribeThroughPoolterminating anobserver in neither direction when the drain cancelled it). This one is a classification error
rather than a cost.
What I measured, and the defect
Orleans'
ResponseTimeoutis a caller-side give-up timer, not a cancellation — when it fires therequest has already been handed to the target activation and stays in its work queue until that
activation gets to it. So a rejection and a timeout are opposite facts about who holds the request:
OrleansMessageRejectionException("… to invalid activation. Rejecting now.") — the calleeREFUSED it and holds nothing. Re-invoking re-resolves placement and the message lands on a
fresh activation. This is the case the retry was built for (Retry a transient pod-hub delivery rejection instead of NACKing on the first attempt #2314).
TimeoutException— the callee ACCEPTED it and has not answered yet. Re-sending duplicatesit.
The retry gated on
IsTransientFailure, which matchesTimeoutException. And nothing on the receivepath can recognise a repeat:
MessageHubGrain.DeliverMessageends inhub.DeliverMessage(delivery)— an unconditional post onto the target hub's queue — and I found noreader of
IMessageDelivery.Idthat dedupes anywhere insrc/. So that is duplicate handlerexecution, not a retry.
The standing rationale for the generosity was "it is bounded by a retry budget". The budget is
bounded in attempts (6) and its delays (250 ms → 3 s, 9.75 s total) are sized for a rejection,
which Orleans returns instantly. A timed-out attempt costs the transport's whole
ResponseTimeoutinstead:
And the coupling runs the wrong way, which is what makes it an amplifier rather than waste. A
response timeout on a delivery leg happens precisely when the destination is slow — a per-node hub
whose
HubReadyhas not emitted, which for an_Activity/compileaddress means an in-mesh NodeTypecompile — or when the silo is CPU/thread starved. In both cases the retry multiplies the work of the
thing that was already too slow, at the moment it has least capacity, while each leg holds one
inFlightRoutesslot for the whole 3 m 40 s. #1172's original evidence is dominated by_Activity/compileand_Activity/importtargets; on this reading those are not incidental, they arethe addresses whose
HubReadytakes longest, i.e. exactly the ones the ladder re-sent to seven times.The fix — a third predicate, not a narrowed one, and no bound is touched
IsResendableDeliveryFailure = !IsResponseTimeout(ex) && IsTransientFailure(ex), walking theexception GRAPH (
ExceptionChain) rather than theInnerExceptionline — these faults arrivethrough Rx
Catcharms and two-transportAggregateExceptions where which fault sits at index 0 is arace. It gates both non-idempotent retry sites:
RoutingGrain.DeliverToGrainObservable(servingIMessageHubGrain.DeliverMessageandIPodHubGrain.Deliver) andOrleansRoutingService.DispatchObservable(IRoutingGrain.RouteMessage, whose callee queue is theone that reached 541 deep in the 2026-08-07 incident).
IsTransientFailureis deliberately left intact — its other caller,AttachWithBoundedRetry'sIPodHubGrain.Attachclaim, IS idempotent (it sets flags and re-pins anactivation), so re-sending it after a timeout costs nothing and is how the claim converges (#2633).
Narrowing in place would have silently disarmed that retry. It is also still the right answer to "is
this fault transient" and is pinned as such by
OrleansDirectoryInstabilityClassificationTestandStreamPostTimeoutAttributionTest, both of which stay green untouched.No retry count, timeout, pool size or threshold is changed, and nothing is swallowed. The fault
reaches the same arm it reached after the retries were exhausted, and
ClassifyDeliveryExceptionalready answers a bare
TimeoutExceptionwith the terminalErrorType.Failed— deliberately, becausetelling a consumer "transient" arms an unbounded resubscribe against a plausibly-wedged target. The
sender therefore gets the identical verdict it always got, one
ResponseTimeoutafter the firstattempt instead of seven of them later.
Negative control
TimedOutDeliveryIsNotResentTest(6 facts, 694 ms, no cluster,Scheduler.Immediate).Reverted only the two gate expressions back to
IsTransientFailure, left everything else inplace, rebuilt Release:
The
found 7is the defect stating its own size. The 4 that stay green are the ladder pins and therejection path — i.e. the control on the other side of the change:
ARejectedDelivery_IsStillResentUntilTheBudgetIsSpentasserts a rejection still spends its fullbudget both before and after, so the fix cannot be "the retry was disabled".
timeoutFirst: Falsealso passes in both directions and is honest about it (an
InnerException-only walker could not seeindex 1 at all) — the doc page says which half is the discriminator and which is the graph-walk pin.
Restored, verified byte-identical to the saved diff, re-ran: 6 of 6 pass, plus 68 of 68
neighbouring routing/classification/dispatcher tests and 601 of 601 documentation tests.
What the other six would inherit, and what they would NOT
Inherited — the shape all 8
deepest 0samples show gets up to 7× fewer legs in the 64-slotbudget and each held for one
ResponseTimeoutinstead of seven, so crossings become rarer andepisodes shorter. On the reading above that is the dominant contributor to the eight breadth samples.
NOT inherited, and I am not claiming it is:
deepest 62on onecache/…(RoutingGrain repeatedly hits 64-dispatch back-pressure threshold on memex-portal pods, with one episode head-of-line blocked on a single cache destination #4897, RoutingGrain dispatch pool saturates with all 64 slots in flight; one destination holds 62 queued legs (head-of-line block), fleet-wide today #4899, Routing grain hits dispatch back-pressure threshold on both portal pods simultaneously; one pod shows a 62-deep head-of-line queue on a single cache destination #4900, RoutingGrain back-pressure recurs: 62-deep head-of-line pileup behind cache hub legs persists past issue #4903's window #4904, RoutingGrain dispatch slots held indefinitely under ThreadPool starvation; back-pressure log fans out into per-activation incidents #4908). Independentroot:
OrderedRouteDispatcher's FIFO key is the destination address while the orderinginvariant it protects (
SynchronizationStream's receive-side monotonicity guard) is per stream— so all traffic to one process's cache hub is serialised whether or not any two frames have an
ordering relationship at all. That over-broadness is what lets one slow destination stack ~62 legs.
Already ticketed at sev:M; deliberately out of scope here rather than filed again.
timeouts are Rx operators inside the cold observable and do not start until
SubscribeThroughPoolobtains a ThreadPool thread. The report's own text names this; it is notclosed by this change.
routing pool subscribingis a structurally uninformative field — 0 in all 10 samples, and~always 0 by construction. Noted for whoever next touches that report.
So: the sev:H framing in #1172's body ("dispatches that never complete", "the Orleans
NonReentrancyQueueSizelimit", "all further routing on that silo stalls") was already disprovenin its own comments, and every root named for it is now fixed — #1341, #1358, and this. The
residual load and head-of-line shapes at that log site are tracked at sev:M/L on the six newer
tickets, which is where I think they belong; reopen if you read the disposition differently.
Verification
dotnet build -c Release -warnaserror, one project per invocation,0 Error(s) 0 Warning(s):MeshWeaver.Connection.Orleans,MeshWeaver.Hosting.Orleans,test/MeshWeaver.Hosting.Orleans.Test(which transitively builds the only two dependents,Hosting.Orleans.TestBaseand the test project itself — nothing else insrc/,test/orsamples/references either touched project),test/MeshWeaver.Documentation.Test.IsTransientFailureand both new predicates areinternal), sono
Pairs-with:/Implementers:/Mirror-sync:obligation.Doc/Architecture/ATimedOutDeliveryIsStillHeldByTheCallee, listed in the Architecture topic map(
ArchitectureTopicMapTestenforces that).src/on the silo path, not nodecontent and not a NodeType, so it takes effect with the image roll.
🤖 Generated with Claude Code