Skip to content

Lazy load breadth-first instead of depth-first #3694

@NuckChorris

Description

@NuckChorris

Is your feature request related to a problem? Please describe.

I want to parallelize batched lazy-loading using Concurrent::Promises while maintaining (most) compatibility with graphql-batch. To achieve this, I was attempting to create a Promise chain:

def field
  Concurrent::Promises.delay do
    # This runs when graphql-ruby calls the defined `lazy_resolve()` on it
    Concurrent::Promises.future do
      # This runs in a background thread to do the actual loading and resolves when that's done
      # More realistically, this would kick off a shared promise (for the batch) and `.then()` on that
    end
  end
end

The hope being that the inner Concurrent::Promises.future block would run in parallel with sibling fields, releasing GVL while the database works.

In reality, however, resolution is done depth-first, so graphql-ruby waits for the inner block to resolve before resolving sibling fields.

Describe the solution you'd like

I would like to propose a change from depth-first to breadth-first resolution of lazy values. That is, resolve siblings before you resolve a nested lazy value. This would enable this use case, as well as improve batching in cases where you have a second layer of resolvers. For example, you might have multiple fields like this:

Loader.for().load().then do |results|
  OtherLoader.for().load(results)
end

Currently, the OtherLoader won't batch across fields, but with a breadth-first approach it would.

Describe alternatives you've considered

One alternative might be to create a resolution queue. As fields return lazy values, push them onto the queue, and resolve them in a first-in-first-out manner similar to the JS event loop until there's nothing left.

Example

Assuming lazy_resolve(::Concurrent::Promises::Future, :value!), and given the following type:

class Types::Query < GraphQL::Schema::Object
  field :field_a, String, null: false

  def field_a
    Concurrent::Promises.delay do
      puts 'A HELLO'
      Concurrent::Promises.delay do
        sleep 5
        puts 'A WORLD'
        'Hello'
      end
    end
  end

  field :field_b, String, null: false

  def field_b
    Concurrent::Promises.delay do
      puts 'B HELLO'
      Concurrent::Promises.delay do
        sleep 5
        puts 'B WORLD'
        'World'
      end
    end
  end
end

For the query { fieldA fieldB } what we want to see (across 5 seconds) is:

A HELLO
B HELLO
A WORLD
B WORLD

But what we see instead (across 10 seconds) is

A HELLO
A WORLD
B HELLO
B WORLD

This is obviously a bit contrived, but this same parallelism would be beneficial to real applications as well, since it would interleave database queries

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions