Repository navigation
One stuck delivery no longer wedges a whole silo's routing - #1037
Conversation
RoutingGrain is [StatelessWorker(1)] and non-reentrant, so the silo has exactly ONE routing turn — and Orleans' request timeout does not apply inside a turn. RouteMessage did delivery work inline on that turn (the memory-stream post was awaited; the path-resolution subscribe ran its whole synchronous prologue there), so a single leg that never terminated stopped every message the silo needed to route. Prod atioz 2026-08-07: one RouteMessage executing 06:00:22 behind NonReentrancyQueueSize=541, Admin/UpdatePolicy unreachable for 37 h. The turn now captures its activation-bound handles and returns; the composed route is subscribed on a mesh-scoped Routing IIoPool. A leg that never terminates costs one pool slot and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r be silent RoutingGrainTurnIsolationTest decorates the silo's real IPathResolver with one whose Subscribe blocks the calling thread, waits until the routing grain is provably inside that stall, then pings an ordinary partition root. Pre-fix the turn is the blocked thread and the ping cannot be served at all. RoutingGrainStreamPostBoundTest pins the other half on a TestScheduler: a stream post that never completes still terminates (releasing the pool slot) and NACKs the sender, so a dropped delivery can never be a silent hang. Also closes an error-propagation gap the move off the turn opened: a synchronous throw during route composition (the GetStream NRE while the stream provider is still starting) used to fault RouteMessage itself and reach the caller's error path; it now NACKs the sender explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #1028 by ensuring a single stuck/blocked RoutingGrain.RouteMessage execution can no longer wedge silo-wide message routing. It does this by moving the potentially-blocking routing work (path resolution, stream post, grain hand-off, and NACK surfacing) off the non-reentrant routing-grain turn and onto a dedicated mesh-scoped Routing IIoPool, while adding bounded failure surfacing and back-pressure visibility.
Changes:
- Refactors
RoutingGrain.RouteMessageto do O(1) work on-turn and dispatch the composed route via the newIoPoolNames.Routingpool, including saturation logging and bounded stream-post failure surfacing. - Adds regression tests that (1) reproduce a synchronous “blocked subscribe” wedge against a real Orleans cluster and (2) pin the stream-post leg’s terminal timeout/NACK contract deterministically via
TestScheduler. - Documents the new
Routingpool and the user-visible “What’s New” fix entry.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Orleans.Test/RoutingGrainTurnIsolationTest.cs | New Orleans-cluster regression test proving a synchronously-blocked route leg no longer blocks unrelated routing. |
| test/MeshWeaver.Hosting.Orleans.Test/RoutingGrainStreamPostBoundTest.cs | New deterministic unit test for stream-post timeout/fault behavior and sender NACK surfacing. |
| test/MeshWeaver.Hosting.Orleans.Test/MeshWeaver.Hosting.Orleans.Test.csproj | Adds Microsoft.Reactive.Testing to support deterministic scheduler-based timeout testing. |
| src/MeshWeaver.Mesh.Contract/Threading/IoPoolOptions.cs | Introduces IoPoolNames.Routing and an IoPoolOptions.Routing cap (default 256), wired into MaxConcurrencyFor. |
| src/MeshWeaver.Hosting/AssemblyInfo.cs | Grants InternalsVisibleTo for MeshWeaver.Hosting.Orleans.Test to support the new routing regression test wiring. |
| src/MeshWeaver.Hosting.Orleans/RoutingGrain.cs | Core fix: dispatch routing work via routingPool.SubscribeThroughPool, add in-flight/back-pressure logging, and implement bounded PostToStream behavior. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-one-stuck-message-no-longer-stops-the-portal.md | Adds a “What’s New” entry describing the user-visible impact of the routing isolation fix. |
| src/MeshWeaver.Documentation/Data/Architecture/ControlledIoPooling.md | Documents the new Routing pool and its purpose as an isolation boundary for routing subscribes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 5)1 725 tests 1 533 ✅ 4m 57s ⏱️ Results for commit dcd97b1. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)690 tests 690 ✅ 12m 30s ⏱️ Results for commit dcd97b1. ♻️ This comment has been updated with latest results. |
…view) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 4)1 284 tests 1 279 ✅ 7m 1s ⏱️ Results for commit dcd97b1. |
Test Results (shard 1)1 199 tests 1 199 ✅ 5m 52s ⏱️ Results for commit dcd97b1. |
Test Results (shard 3) 11 files 11 suites 5m 47s ⏱️ Results for commit dcd97b1. |
Test Results (shard 2)1 912 tests 1 910 ✅ 6m 28s ⏱️ Results for commit dcd97b1. |
Test Results 65 files 65 suites 42m 38s ⏱️ Results for commit dcd97b1. |
Fixes #1028.
What the evidence actually says
I pulled the original wedge out of Loki (client-i, pod
memex-portal-deployment-76855f7cdd-gm69g, 2026-08-07 15:14 UTC onward). Three facts fall out, and the third contradicts the issue's premise:The wedge starts on a fresh activation, ~35 s into pod startup, during the startup burst (
ShippedReleaseSeedfiring ~20AgenticPension/*release triggers,DynamicTypePreWarmerbuilding 4 NodeTypes,StaticRepoImportsyncing 4 sources) — all funnelled through the one routing worker.IdlenessTimeSpan=00:00:34,Total Enqueued=3; Total processed=2.RouteMessagenever RETURNED — it was blocked in its own synchronous body. Orleans' diagnostic reportsExecuting Task Id=1 Status=Running for 00:00:29.463withQueuedWorkItems=0andTotal processedfrozen at 2. Orleans' request work item completes as soon as the grain method returns itsTask; a turn awaiting something would have shownprocessed=3and the activation merely waiting. It didn't. The turn was synchronously stuck.It was not an awaited grain call. Grain calls are bounded by Orleans' 30 s
ResponseTimeout, and those timeouts absolutely do get logged here — over the three days I queried there are 2068IRoutingGrain.RouteMessageand 179IMessageHubGrain.DeliverMessage"Response did not arrive on time" lines, and zero forIMemoryStreamQueueGrain.Enqueue.That third point answers question 1 in the issue, in the negative.
IAsyncStream.OnNextAsyncon an Orleans memory stream has exactly one await —queueGrain.Enqueue(...)(MemoryAdapterFactory.QueueMessageBatchAsync, verified against Orleans v10.2.2 source) — and that award is bounded and would have been logged. So "a memory-streamOnNextAsyncthat never completes" is not what happened. What actually has no bound is the synchronous workRouteMessageperformed on the turn, on both branches:GetStream(...)plusOnNextAsync's synchronous prologue (Orleans serialization of the delivery) — and then it awaited the post, so the turn's completion was the post's completion;pathResolver.ResolvePath(...).Subscribe(...)— a cache hit emits synchronously, so the whole handler (including the grain-call marshalling and the failure post) ran inline on the turn.I could not identify the exact blocking primitive from the surviving evidence — the pod is gone and there is no dump — and I am not going to guess one. What is determinable is stronger and sufficient: the turn executes an unbounded amount of foreign code, and
RoutingGrainis[StatelessWorker(1)]+ non-reentrant, so that is one shared, unbounded, silo-wide critical section by construction.The fix (question 2)
A timeout on the turn is the wrong bound and could not have worked anyway: you cannot abandon a synchronously blocked thread — a
.Timeout(...)wrapped around it never gets a chance to fire, because the calling thread never returns to observe it. The cure has to be structural.RouteMessagenow captures its two activation-bound handles (GetStreamProvider,GrainFactory— both O(1) lookups) and returns. The composed route — path resolution, the memory-stream post, the per-node grain hand-off, theDeliveryFailureNACK — is subscribed on a new mesh-scopedRoutingIIoPoolviaSubscribeThroughPool, which runs the subscribe (the synchronous prologue that wedged prod) on a ThreadPool thread, gated and drainable. A leg that never terminates now costs one pool slot and nothing else.No caller-visible contract moves.
RouteMessagealready returnedForwardedunconditionally — the old stream branch'sContinueWithreturnedForwardedwhether the post succeeded or faulted. A delivery's real outcome has always come back through the response /DeliveryFailurepath on the sender's hub, and still does.Bounds that are about reporting, not suppression. Each leg terminates and tells the sender:
.Timeout— it can only fire after Orleans' own 30 s bound on its single grain call has already been exceeded, i.e. when the post is provably never going to land. It converts a silent never-completing leg into aLogError+ aDeliveryFailureat the sender, and releases the pool slot. It is not a retry and not a queue bound.GetStreamNRE'ing out ofPersistentStreamProvider.IsRewindablewhile the stream provider is still starting — that NRE is in these very logs) now NACKs the sender explicitly. Pre-fix it faultedRouteMessageitself and reached the caller's error path; moving off the turn would otherwise have turned it into a log line and a parked sender. That gap is closed here.Detection. The grain counts in-flight route dispatches and reports at
LogCriticalwhen 64 are outstanding and not terminating, with a recovery line when it drains. Event-driven at the dispatch site — no timer, no watchdog, no poller. This replaces the signal that was previously invisible:NonReentrancyQueueSizegrowing to 541 inside an Orleans warning that nobody was watching.Tests, and the negative control run explicitly
RoutingGrainTurnIsolationTest(realOrleansTestBasecluster) decorates the silo's realIPathResolverwith one whoseSubscribeblocks the calling thread for a magic partition — the prod failure mode, reproduced deterministically. It fires one delivery into that partition, waits (reading the silo-side decorator directly, so there is no ordering race and no sleep) until the routing grain is provably inside the stall, then pings an ordinary partition root and asserts the stall is still running when the ping is answered.I verified the negative control by reverting
RoutingGrain.csto the pre-fix version and re-running:RoutingGrainTaskCanceledExceptionat the 10 s probe budget; the probe cannot be served at allRoutingGrainStreamPostBoundTestpins the terminal contract on aTestScheduler: a post that never completes still terminates and NACKs the sender; a faulting post NACKs; a delivered post does not. It also carries an explicitNegativeControl_AwaitingThePostInsideTheTurn_NeverReturnsdocumenting why the old shape had no bound.Local: 29/29 routing-focused Orleans tests green,
DocumentationLinkIntegrityTestgreen,dotnet build -c Release -p:CIRun=true -warnaserrorclean.Trade-off worth a reviewer's eye
Stream posts used to be serialized by the single in-turn await, so posts to a given target were de-facto FIFO. They are now concurrent. I judged that acceptable because Orleans gives no ordering guarantee for the grain calls into the routing grain in the first place (so nothing may depend on it end-to-end), and because the cross-hub write contract is explicitly eventual-consistency-safe — RFC 7396 merge patches applied against the owner's current state, documented in AGENTS.md as safe against concurrent writers. If you would rather keep strict FIFO, the alternative is a
Subject+Concatserial queue off the turn; say the word and I will add it.Related, deliberately out of scope
MessageHubGrain.DeliverMessagehas the same in-turn shape (HubReady.Take(1).Subscribe(...)running inline), but it is a per-node grain, so a wedge there is bounded to that one node's hub rather than the whole silo — consistent with the 179DeliverMessagetimeouts in the logs being spread across nodes. Worth a separate look, not a change here.🤖 Generated with Claude Code