Run GraphQL Ruby resolver in parallel

This article is the 20th day article of GraphQL Advent Calendar 2020. Yesterday was @ marin_a__'s How to use LocalState in Apollo Client x codegen.


Suppose you have a GraphQL schema like this:

type Query {
  articles: [Article!]
  users: [User!]
}

type Article {
  title: String!
}

type User {
  avatar: String!
}

Suppose Article, User, and Avatar are separate microservices.

image.png

Also assume that the APIs provided by the individual services are:

--Article service returns articles (ArticleServiceClient.get_articles) --User service returns users (no avatar, only id) (UserServiceClient.get_users) --The Avatar service takes a user id and returns an avatar (AvatarServiceClient.get_avatar (id))

At this point, consider resolving a query like this:

query {
  articles {
    title
  }
  users {
    avatar
  }
}

If you write it honestly, it looks like the following.

class ArticleType < GraphQL::Schema::Object
  field :title, String, null: false
end

class UserType < GraphQL::Schema::Object
  field :avatar, String, null: false

  def avatar
    AvatarServiceClient.get_avatar(object.id)
  end
end

class QueryType < GraphQL::Schema::Object
  field :users, [UserType], null: true
  field :articles, [ArticleType], null: true

  def users
    UserServiceClient.get_users
  end

  def articles
    ArticleServiceClient.get_articles
  end
end

class TestSchema < GraphQL::Schema
  query QueryType
end

Let's say it takes 2 seconds to return users, 3 seconds to return articles, 1 second to return avatar, and if there are 3 users, a total of 2 + 3 + 1 * 3 takes 8 seconds. I will.

image.png

However, since articles and users are schema independent, it seems possible to make requests in parallel. GraphQL Ruby has Mechanism for lazy evaluation, which can be combined with concurrent-ruby for parallel processing [^ 1].

Specifically, it can be realized by wrapping the part that makes a request to the service in Concurrent :: Promises.future and setting it tolazy_resolveas shown below.

class UserType < GraphQL::Schema::Object
  def avatar
    Concurrent::Promises.future do
      AvatarServiceClient.get_avatar(object.id)
    end
  end
end

class QueryType < GraphQL::Schema::Object
  def users
    Concurrent::Promises.future do
      UserServiceClient.get_users
    end
  end

  def articles
    Concurrent::Promises.future do
      ArticleServiceClient.get_articles
    end
  end
end

class TestSchema < GraphQL::Schema
  lazy_resolve Concurrent::Promises::Future, :value!
end

However, GraphQL Ruby's lazy_resolve seems to wait for all sibling fields. In this example, users can return articles earlier than 1 second articles, but wait for articles before avatar is evaluated. This means that if avatar runs immediately after users, it only takes 3 seconds, but it actually takes 4 seconds.

code

You can check the operation by saving the following code and doing $ ARTICLE_SERVICE_SLEEP = 3 USER_SERVICE_SLEEP = 2 AVATAR_SERVICE_SLEEP = 1 FUTURE = 1 ruby ​​a.rb. It can be confirmed that the execution time of a little over 8 seconds becomes a little over 4 seconds depending on the presence or absence of FUTURE = 1.

require "bundler/inline"

gemfile do
  source "https://rubygems.org"

  gem "graphql"
  gem "concurrent-ruby"
end

query = <<~Q
  query {
    articles {
      title
    }
    users {
      avatar
    }
  }
Q

require 'logger'

$logger = Logger.new(STDOUT)

module ArticleServiceClient
  Article = Struct.new(:id, :title, keyword_init: true)

  ALL_ARTICLES = Array.new(3) do |i|
    Article.new(id: i, title: "title#{i}")
  end

  def self.get_articles
    $logger.debug("#{name}##{__method__} start")
    sleep ENV['ARTICLE_SERVICE_SLEEP'].to_i
    $logger.debug("#{name}##{__method__} end")

    ALL_ARTICLES
  end
end

module UserServiceClient
  User = Struct.new(:id, keyword_init: true)

  ALL_USERS = Array.new(3) do |i|
    User.new(id: i)
  end

  def self.get_users
    $logger.debug("#{name}##{__method__} start")
    sleep ENV['USER_SERVICE_SLEEP'].to_i
    $logger.debug("#{name}##{__method__} end")

    ALL_USERS
  end
end

module AvatarServiceClient
  def self.get_avatar(id)
    $logger.debug("#{name}##{__method__} start")
    sleep ENV['AVATAR_SERVICE_SLEEP'].to_i
    $logger.debug("#{name}##{__method__} end")

    "#{id}.jpg "
  end
end

class ArticleType < GraphQL::Schema::Object
  field :title, String, null: false

  def title
    $logger.debug("#{self.class}##{__method__}")
    object.title
  end
end

class UserType < GraphQL::Schema::Object
  field :avatar, String, null: false

  def avatar
    if ENV['FUTURE']
      Concurrent::Promises.future do
        AvatarServiceClient.get_avatar(object.id)
      end
    else
      AvatarServiceClient.get_avatar(object.id)
    end
  end
end

class QueryType < GraphQL::Schema::Object
  field :articles, [ArticleType], null: true
  field :users, [UserType], null: true

  def articles
    if ENV['FUTURE']
      Concurrent::Promises.future do
        ArticleServiceClient.get_articles
      end
    else
      ArticleServiceClient.get_articles
    end
  end

  def users
    if ENV['FUTURE']
      Concurrent::Promises.future do
        UserServiceClient.get_users
      end
    else
      UserServiceClient.get_users
    end
  end
end

class TestSchema < GraphQL::Schema
  query QueryType
  lazy_resolve Concurrent::Promises::Future, :value!
end

pp TestSchema.execute(query).to_h

__END__
$ time ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 ruby a.rb 
D, [2020-12-20T12:11:14.873364 #23151] DEBUG -- : ArticleServiceClient#get_articles start
D, [2020-12-20T12:11:17.876517 #23151] DEBUG -- : ArticleServiceClient#get_articles end
D, [2020-12-20T12:11:17.876831 #23151] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:17.876937 #23151] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:17.877046 #23151] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:17.877143 #23151] DEBUG -- : UserServiceClient#get_users start
D, [2020-12-20T12:11:19.879268 #23151] DEBUG -- : UserServiceClient#get_users end
D, [2020-12-20T12:11:19.879546 #23151] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:20.880675 #23151] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:20.880875 #23151] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:21.882006 #23151] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:21.882193 #23151] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:22.883306 #23151] DEBUG -- : AvatarServiceClient#get_avatar end
{"data"=>
  {"articles"=>[{"title"=>"title0"}, {"title"=>"title1"}, {"title"=>"title2"}],
   "users"=>[{"avatar"=>"0.jpg "}, {"avatar"=>"1.jpg "}, {"avatar"=>"2.jpg "}]}}
ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 ruby a.rb  0.26s user 0.02s system 3% cpu 8.286 total

$ time ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 FUTURE=1 ruby a.rb
D, [2020-12-20T12:11:37.120206 #23200] DEBUG -- : ArticleServiceClient#get_articles start
D, [2020-12-20T12:11:37.120294 #23200] DEBUG -- : UserServiceClient#get_users start
D, [2020-12-20T12:11:39.120599 #23200] DEBUG -- : UserServiceClient#get_users end
D, [2020-12-20T12:11:40.120562 #23200] DEBUG -- : ArticleServiceClient#get_articles end
D, [2020-12-20T12:11:40.120985 #23200] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:40.121188 #23200] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:40.121261 #23200] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:40.121934 #23200] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:40.121994 #23200] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:40.122088 #23200] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:41.122276 #23200] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:41.122413 #23200] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:41.122499 #23200] DEBUG -- : AvatarServiceClient#get_avatar end
{"data"=>
  {"articles"=>[{"title"=>"title0"}, {"title"=>"title1"}, {"title"=>"title2"}],
   "users"=>[{"avatar"=>"0.jpg "}, {"avatar"=>"1.jpg "}, {"avatar"=>"2.jpg "}]}}
ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 FUTURE=1   0.22s user 0.07s system 6% cpu 4.285 total

[^ 1]: Since Ruby Thread is used, it is not parallel but parallel at Ruby level due to GVL. However, in the case of waiting for IO, GVL is released, so inter-service communication like this one is performed in parallel. https://docs.ruby-lang.org/ja/latest/doc/spec=2fthread.html

Recommended Posts

Run GraphQL Ruby resolver in parallel
Class in Ruby
Heavy in Ruby! ??
How to implement Pagination in GraphQL (for ruby)
About eval in Ruby
Output triangle in Ruby
Variable type in ruby
Fast popcount in Ruby
Parallel execution in Java
Introduction to Parallel Processing + New Parallel Execution Unit in Ruby Ractor
Write DiscordBot to Spreadsheets Write in Ruby and run with Docker
ABC177 --solving E in Ruby
Validate JWT token in Ruby
Implemented XPath 1.0 parser in Ruby
Read design patterns in Ruby
GraphQL Client starting with Ruby
GraphQL Ruby and actual development
Write class inheritance in Ruby
Update Ruby in Unicorn environment
Integer unified into Integer in Ruby 2.4
[Ruby] Exception handling in functions
Use ruby variables in javascript.
Multiplication in a Ruby array
About regular expressions in Ruby
Test GraphQL resolver with rspec
Measured parallel processing in Java
Birthday attack calculation in Ruby
Judgment of fractions in Ruby
Find Roman numerals in Ruby
Try using gRPC in Ruby
[Ruby] Find numbers in arrays
NCk mod p in Ruby
Chinese Remainder Theorem in Ruby