Fix Dispatchers.IO starvation deadlock in notification send path - #1248
Conversation
SendMessageActionHelper used runBlocking inside code already running on
a Dispatchers.IO coroutine (PluginBaseAction launches every transport
request on CoroutineScope(Dispatchers.IO)). The parent coroutine parked
its dispatcher thread while awaiting child async(Dispatchers.IO) sends
that need threads from the same pool.
Under sustained concurrent sends to a slow destination (e.g. a webhook
that times out), parents eventually occupy all 64 Dispatchers.IO slots.
At that point no child can ever be scheduled and no parent can ever
complete: a permanent dispatcher deadlock. Because all Notifications
transport actions share the same scope, every plugin API (e.g.
GET /_plugins/_notifications/features) hangs until the node is
restarted.
Replace runBlocking with structured concurrency: the send-path
functions become suspend functions and use coroutineScope { ... }
+ awaitAll(), so waiting parents suspend and release their dispatcher
threads instead of parking them. All call chains already terminate in
suspend transport-action methods, so no additional bridging is needed.
Reproduced by flooding 200 concurrent send requests at a webhook
channel that responds after 30s: before this change the node's
notifications APIs deadlock permanently (64 threads parked in
runBlocking at sendMessagesInParallel, zero threads performing sends);
after this change all sends complete and APIs remain responsive.
Signed-off-by: Surya Sashank Nistala <snistala@amazon.com>
Review: ✅ ApproveWell-written fix for a real production deadlock. Categorized findings below: 🔴 Critical (0)None. The fix is correct and complete. 🟡 Non-blocking (2)
💬 Minor/Nit (2)
SummaryTextbook fix for a well-known Kotlin coroutines anti-pattern ( |
Signed-off-by: Surya Sashank Nistala <snistala@amazon.com>
runBlocking implements the same structured concurrency semantics: it creates a scope, and a failed async child propagates to the parent Job immediately, cancelling all siblings — then runBlocking rethrows. Failure/cancellation behavior between runBlocking { async {} } and coroutineScope { async {} } is identical
It fails compilation without suspend is this is trivial but added comment.
I acknowledge this but Notifications plugin currently has no real integ test sending a message to a real channel. we need to explore that indepedent of this PR |
Description
Fixes a permanent
Dispatchers.IOstarvation deadlock in the notification send path.SendMessageActionHelperusedrunBlockinginside code already running on aDispatchers.IOcoroutine (PluginBaseActionlaunches every transport request on a sharedCoroutineScope(Dispatchers.IO)). The parent coroutine parked its dispatcher thread while awaiting childasync(Dispatchers.IO)sends that need threads from the same pool. Under sustained concurrent sends to a slow destination (e.g. a webhook that responds slowly or times out), parents eventually occupy all 64Dispatchers.IOslots; from then on no child can be scheduled and no parent can complete. All Notifications APIs (includingGET /_plugins/_notifications/features) hang until the node is restarted. Observed in production on 2.19; code is unchanged on main.Change: replace all four
runBlockingsites inSendMessageActionHelperwith structured concurrency — the send-path functions becomesuspendfunctions usingcoroutineScope { ... }+awaitAll(), so waiting parents suspend and release their dispatcher threads instead of parking them. All call chains already terminate insuspendtransport-action methods (SendNotificationAction.executeRequest,PublishNotificationAction.executeRequest), so no additional bridging is needed. No behavior change to what is sent, delivery statuses, or parallelism — only how waiting happens (suspension vs. thread parking).Converted functions:
sendMessagesInParallel→suspend+coroutineScope(primary deadlock site)executeLegacyRequest→suspend,runBlockingwrapper removedsendEmailMessage→suspend+coroutineScope(recipient fan-out; previously a nestedrunBlockinginside a child of the outer one)sendMessageToLegacyDestination→suspend+coroutineScope(legacy email branch)sendMessageToChannel→suspend(transitively required)Verification: reproduced the deadlock on an integTest cluster (main) by flooding 200 concurrent
feature/test/{config_id}requests at a webhook channel responding after 30s: before this change the node deadlocks permanently (jstack: exactly 64DefaultDispatcher-workerthreads parked inrunBlockingatsendMessagesInParallel, zero threads inDestinationHttpClient, no recovery after the webhook is stopped). After this change, all 200 sends complete with correct delivery-failure statuses and all plugin APIs remain responsive throughout; jstack shows no parked parents. Full repro details in the linked issue.Related Issues
Resolves #1247
Check List
--signoff.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.