AsyncDataloader: avoid ClosedError race condition with straggler fibers - #5702
Open
drhops wants to merge 1 commit into
Open
AsyncDataloader: avoid ClosedError race condition with straggler fibers#5702drhops wants to merge 1 commit into
drhops wants to merge 1 commit into
Conversation
…error path When run_queue exits via check_error!, its ensure closes the tasks_channel while sibling tasks can still be parked in non-Dataloader sleeps/IO. Cancelling a task transfers control into it (IO::Event::Selector#raise pushes the calling fiber onto the ready list first), so between close_queues and root_task.cancel reaching every straggler, the scheduler may resume a task whose IO already finished. That task's next dataloader.yield pushed [:paused_task, ...] into the closed queue, leaking Async::Queue::ClosedError into user code, and the rescue's [:task_error, ...] push hit the closed queue too, losing the error report. Route all task messages through Run#push_task_message, which tolerates a closed channel: :task_error payloads are recorded directly on the Run so they can't be lost, and yield stops the current task when the channel is gone - which is exactly what root_task.cancel was about to do.
drhops
force-pushed
the
fix-async-dataloader-closed-queue-race
branch
from
August 15, 2026 03:14
42a28f5 to
61e61bb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
AsyncDataloadercan leakAsync::Queue::ClosedErrorinto resolver code, and lose the task's own error report when:StandardError, sorun_queue'scheck_error!re-raises and itsensurerunsrun.close_queues, andIntroduced with the queue-based rewrite in #5479 (2.6.4+). Related prior reports: #5654 (#5656) and #5671 (#5672, #5679). All of those fixes are in 2.6.8, but this race survives them, which matches the #5671 reporter's observation that the warnings went away while a hang remained.
Production stacktrace
Seen intermittently in production (Puma, Ruby 3.4,
graphql2.6.8,async2.36.0,config.active_support.isolation_level = :fiber,fiber_limit: 16, sources doing Redis + Postgres I/O):Minimal repro
Single-threaded, no GraphQL execution needed. A job error while siblings sit in plain
sleeps is the whole trigger:With randomized delays this reproduces within a few dozen iterations on
master, against both async 2.36.0 and async 2.44.1. A single straggler job (SlowSource) reproduces the issue ~1% of runs, while three stragglers increases repro rate to ~5%.Root cause
Closing the channel and stopping the fibers that report to it are meant to be one atomic act, but the scheduler can interleave another fiber between them.
The stragglers are accounted for and sit in
@running_tasks, so bookkeeping is accurate. On the error path the round is deliberately abandoned with straggler fibers still parked:check_error!raises, theensurecloses the tasks_channel, androot_task.cancelis about to stop everyone. But teardown itself yields control: cancelling a non-current task transfers into it (IO::Event::Selector#raisepushes the calling fiber onto the ready list, thenfiber.raises into the target), and each transfer gives the reactor a chance to resume whatever else became ready.So a parked straggler's wakeup (sleep timer, IO completion, etc) has three possible timings:
Stopis raised at its suspension point; it unwinds without pushing. Fine.close_queues, before its own cancel. The reactor resumes it and it runs one normal slice: itsdataloader.yieldpushes[:paused_task, ...]into the closed channel andClosedErrorleaks into resolver code (async_dataloader.rb:39); its rescue then pushes[:task_error, ClosedError]into the same closed channel (:339) → a secondClosedError, an unhandled task exception, and a lost error report.The window is microseconds wide, which is why the failure is intermittent and timing-dependent, and why every additional mid-flight fiber is another independent chance to land in it.
Fix
Route all four task-message pushes through a new
Run#push_task_message, which tolerates a closed channel::task_errorpayloads are recorded directly on theRun(@task_error ||= err) so an error report is never lost to a closed channel.yield's two push sites dopush_task_message(...) || task.stop: a straggler that finds the channel closed stops itself, exactly whatroot_task.cancelwas about to do a moment later, instead of leakingClosedErrorinto resolver code.:finished_taskon a closed channel is discarded: the round's accounting is already finished, and the task completes cleanly.Tests
New regression test in
spec/graphql/dataloader/async_dataloader_spec.rb("doesn't leak Async::Queue::ClosedError into straggler fibers or lose the original error"): seeded randomized straggler/error timing over up to 500 dataloader runs, asserting that noClosedErrorreaches job code and thatdataloader.runalways re-raises the injected job error. It fails onmasterwithin ~10 iterations and passes with this change (~2s). Existing dataloader specs (including the #5671 stress test) still pass.