Skip to content

AsyncDataloader: when Fibers share a connection, only load connection nodes once - #5708

Open
drhops wants to merge 1 commit into
rmosolgo:masterfrom
drhops:fix-pagination-relation-connection-fiber-safety
Open

AsyncDataloader: when Fibers share a connection, only load connection nodes once#5708
drhops wants to merge 1 commit into
rmosolgo:masterfrom
drhops:fix-pagination-relation-connection-fiber-safety

Conversation

@drhops

@drhops drhops commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Under AsyncDataloader, sibling fields of one connection (edges, pageInfo, …) are separate jobs, each in its own Fiber, and all of them call RelationConnection#load_nodes on the same connection object. @nodes ||= limited_nodes.to_a is not atomic across Fibers. The first caller suspends inside to_a while the query is in flight, @nodes is still nil, so every other caller that arrives during the query loads the same relation again.

This duplicates the page query runs once per Fiber. With ActiveRecord it can also raise ActiveRecord::UnmodifiableRelation: the first load marks the Relation @loaded, and a second Fiber still building Arel for it then trips assert_modifiable! (build_arel writes references_values back to the Relation when group/select use a dotted column name).

Production stacktrace

Puma, Ruby 3.4.9, Rails 8.1, graphql 2.6.8, async 2.36.0, config.active_support.isolation_level = :fiber. The document selected edges, pageInfo { hasNextPage endCursor } and totalCount on a connection over User.joins(...).group("users.id"). edges loaded the relation first; the pageInfo.endCursor Fiber was already inside build_arel:

ActiveRecord::UnmodifiableRelation: Resolving PageInfo.endCursor

  graphql/pagination/connection.rb:214:in 'end_cursor'          # nodes.last && cursor_for(nodes.last)
  graphql/pagination/relation_connection.rb:9:in 'nodes'
  graphql/pagination/relation_connection.rb:224:in 'load_nodes' # @nodes ||= limited_nodes.to_a
  active_record/relation.rb:1201:in 'load'
  active_record/relation.rb:1456:in 'exec_main_query'
  active_record/relation/query_methods.rb:1759:in 'build_arel'  # arel.group(*arel_columns(group_values))
  active_record/relation/query_methods.rb:1975:in 'arel_column_with_table'
                                                                # self.references_values |= [...]
  active_record/relation/query_methods.rb:179:in 'references_values='
  active_record/relation/query_methods.rb:1747:in 'assert_modifiable!'
                                                                # raise UnmodifiableRelation if @loaded || @arel

It is rare because the second Fiber has to suspend inside build_arel — in practice a cold schema cache (columns_hash hitting the DB). The duplicate query happens every time.

Minimal repro

No GraphQL execution needed — two Fibers and one connection are the whole trigger:

class SlowLoadingRelation
  attr_reader :load_count
  def initialize(relation) = (@relation = relation; @load_count = 0)
  def to_a
    @load_count += 1
    sleep(0.1)          # any IO: hands control back to the scheduler
    @relation.to_a
  end
end

class SlowLoadingRelationConnection < GraphQL::Pagination::ActiveRecordRelationConnection
  def load_count = @slow_nodes ? @slow_nodes.load_count : 0
  private
  def limited_nodes = (@slow_nodes ||= SlowLoadingRelation.new(super))
end

connection = SlowLoadingRelationConnection.new(Food.all, first: 2, max_page_size: 10)
Sync { 3.times.map { Async { connection.nodes } }.each(&:wait) }
connection.load_count # => 3 on master, 1 with this change

Root cause

load_nodes does a read (@nodes), an IO-bound call (to_a) and a write, with a suspension point in the middle, on a limited_nodes object that is itself memoized and therefore shared by every caller. ArrayConnection#load_nodes has the same shape but is a pure array slice with no suspension point, so racing it is harmless; RelationConnection is the one that does IO on shared state.

Fix

Guard the load with a Mutex, behind the existing memo as a fast path:

def load_nodes
  return @nodes if @nodes
  (@load_lock ||= Mutex.new).synchronize do
    @nodes ||= limited_nodes.to_a
  end
end

Mutex is Fiber-aware: a waiting Fiber yields to the scheduler rather than blocking the thread, and wakes to find @nodes filled. It lives in Pagination::RelationConnection so Sequel and Mongoid connections get it too. Non-async dataloaders pay one uncontended synchronize per connection, on the first nodes/cursor_for call.

Loading a copy instead (limited_nodes.dup.to_a) would avoid the exception but still run the query once per Fiber, so the lock is the suggested root fix.

Tests

spec/graphql/pagination/active_record_relation_connection_spec.rb: "loads the page only once when several Fibers resolve it". Guarded by RUBY_VERSION >= "3.2.0" like the other async specs. Fails on master, passes with this change.

`AsyncDataloader` resolves sibling fields of one connection (`edges` and
`pageInfo`, for instance) as separate jobs, each in its own Fiber. Both
reach `RelationConnection#load_nodes`, where `@nodes ||= limited_nodes.to_a`
is not atomic across Fibers, so both can call `.to_a` on the same memoized
`limited_nodes` relation.

That runs the page query twice. With ActiveRecord it can also raise
`ActiveRecord::UnmodifiableRelation` in the slower Fiber: loading a
Relation marks it immutable, and building Arel for it can write back to
the Relation (a dotted `group("users.id")`, say, appends to
`references_values`), which then trips `assert_modifiable!`.

Guard the load with a Fiber-aware `Mutex`, behind a memoized fast path so
the lock is only taken until the first load completes.
@drhops drhops changed the title AsyncDataloader: only load connection nodes once when Fibers share a connection AsyncDataloader: when Fibers share a connection, only load connection nodes once Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant