graphql-ruby icon indicating copy to clipboard operation
graphql-ruby copied to clipboard

Support Lazy Enumerators with Stream

Open rmosolgo opened this issue 1 year ago • 4 comments
trafficstars

Currently @stream will defer child selections on list items, but all items are enumerated before any child selections are run.

A better workflow might be that, when a @streamed list is resolved, there's no call to .each right away. Instead, and empty list is put in the response, and then each deferral gets the item it needs and resolves its subfields.

Here's a script to demonstrate the behavior in question:

`@stream` with lazy enumeration

require "bundler/inline"

gemfile do
  gem "graphql", path: "./"  # "~>2.2.0"
  gem "graphql-pro", "~>1.0"
end

class MySchema < GraphQL::Schema
  class LazyItems
    DATA = [
      OpenStruct.new(name: "Stopwatch"),
      OpenStruct.new(name: "Necktie"),
      OpenStruct.new(name: "Suitcase"),
      OpenStruct.new(name: "Chopstick"),
      OpenStruct.new(name: "Peanut"),
      OpenStruct.new(name: "Screwdriver"),
    ]


    def each
      idx = 0
      while idx < DATA.length
        item = DATA[idx]
        puts "Yielding #{idx}: #{item.name}"
        yield(item)
        idx += 1
      end
      self
    end
  end

  class Item < GraphQL::Schema::Object
    field :name, String

    def name
      puts "Resolving Item#name: #{object.name}"
      object.name
    end
  end

  class Query < GraphQL::Schema::Object
    field :items, [Item]

    def items
      puts "Resolving items"
      LazyItems.new
    end
  end

  query(Query)
  use GraphQL::Pro::Stream
end

query_str = "{ items @stream { name } }"

res = MySchema.execute(query_str)
res.context[:defer].each do |deferral|
  deferral.to_h
end
pp res.to_h

It should alternate between yield and resolve messages, but instead, it does all the yields then all the resolves.

If the implementation supported lazy enumeration, then you could use @stream to break up database requests into smaller chunks using a cursor.

rmosolgo avatar Apr 10 '24 18:04 rmosolgo

We're currently looking into using streaming more for our APIs for the https://shop.app. Because the current lack of support for lazy enumerators, per this issue, we have all such APIs separate from our main APIs, and don't use GraphQL. Big ❤️ for this.

tgwizard avatar Aug 28 '24 12:08 tgwizard

Hey, thanks for chiming in, @tgwizard. I started looking into the implementation for this and determined it was going to be ... tricky. It's still definitely my goal ... but no low-hanging fruit here 😅

rmosolgo avatar Aug 28 '24 13:08 rmosolgo

@rmosolgo yeah, I got that from the trampolining comment in the PR 😄 However I can help I'd be happy to do it.

tgwizard avatar Aug 29 '24 07:08 tgwizard

In GraphQL-Pro v1.29.7, GraphQL::Pro::FutureStream supports lazy enumerators with GraphQL v2.5.6+. To use it, add to your schema:

  # Add the @stream directive 
  use GraphQL::Pro::FutureStream 

Then, for any field where you want to support @stream, add streamable: true:

field :users, [User], streamable: true

You could also make all list fields streamable:

class Types::BaseField < GraphQL::Schema::Field 
  def initialize(*args, streamable: nil, **kwargs, &block)
    super(*args, **kwargs, &block)
    if streamable == true ||
        (streamable.nil? && (type.list? || (type.non_null? && type.of_type.list?)))
      extension(GraphQL::Pro::FutureStream::StreamableExtension)
    end
  end
end 

Probably the next release will have a straightforward API for that.

After that, streamable fields that return lazy enumerators will properly stream items without resolving the whole list!


I updated the script above and confirmed it now outputs the expected thing:

Resolving items
Yielding 0: Stopwatch
Yielding 1: Necktie
Resolving Item#name: Stopwatch
Yielding 2: Suitcase
Resolving Item#name: Necktie
Yielding 3: Chopstick
Resolving Item#name: Suitcase
Yielding 4: Peanut
Resolving Item#name: Chopstick
Yielding 5: Screwdriver
Resolving Item#name: Peanut
Resolving Item#name: Screwdriver
{"data" =>
  {"items" =>
    [{"name" => "Stopwatch"},
     {"name" => "Necktie"},
     {"name" => "Suitcase"},
     {"name" => "Chopstick"},
     {"name" => "Peanut"},
     {"name" => "Screwdriver"}]}}

It has Yielding 0 and Yielding 1 right after one another because FutureStream uses enumerator.peek to determine if hasNext should be true or false. If the Enumerator was created with a size, it uses that instead, but the script above doesn't provide a size.

rmosolgo avatar May 13 '25 11:05 rmosolgo