GraphQL Ruby の resolver を並行に動かす

この記事は、[GraphQL Advent Calendar 2020](https://qiita.com/advent-calendar/2020/graphql) 20 日目の記事です。
昨日は、@marin_a__ さんの [Apollo Client × codegen でのLocalStateの使い方](https://qiita.com/marin_a__/items/9f47bf04ddc44aec4247) でした。

---

次のような GraphQL schema があるとします。

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

type Article {
  title: String!
}

type User {
  avatar: String!
}
```

Article、User、そして Avatar が別々のマイクロサービスになっているとします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28402/943cdd9a-f71a-ca1a-f532-3669814e5567.png)

また、個々のサービスが提供している API が次の通りだとします。

- Article サービスは articles を返す (`ArticleServiceClient.get_articles`)
- User サービスは users (avatar はなく、id のみ) を返す (`UserServiceClient.get_users`)
- Avatar サービスは user id を受け取って avatar を返す (`AvatarServiceClient.get_avatar(id)`)

このときに、次のような query を解決することを考えてみましょう。

```graphql
query {
  articles {
    title
  }
  users {
    avatar
  }
}
```

素直に書くと次のようになりそうです。

```ruby
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
```

このとき、users を返すのに 2 秒、articles を返すのに 3 秒、avatar を返すのに 1 秒かかるとし、users が 3 人いるとすると、合計で 2 + 3 + 1 * 3 で 8 秒かかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28402/c838ca1b-50f4-2136-1a48-694a9bbc3e82.png)


しかし、articles と users は schema 上独立しているため、並列にリクエストを投げることができそうに思えます。
GraphQL Ruby では、[遅延評価するための仕組み](https://graphql-ruby.org/schema/lazy_execution.html)があり、これを concurrent-ruby と組み合わせることで並行処理 [^1] をすることができます。

具体的には、以下のようにサービスへのリクエストをする部分を `Concurrent::Promises.future` で包み、`lazy_resolve ` に設定をすることで実現できます。

```ruby
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
```

ただし、GraphQL Ruby の `lazy_resolve` は兄弟関係にある field すべてを待ち合わせるようです。
この例では users が 1 秒 articles よりも先に返せますが、articles を待ち合わせてから avatar が評価されます。
つまり、users のあとすぐに avatar が動けば 3 秒で済むところ、実際には 4 秒かかることになります。

## コード

以下のコードを保存して、`$ ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 FUTURE=1 ruby a.rb` のようにすると動作を確認することができます。 `FUTURE=1` の有無によって 8 秒強の実行時間が 4 秒強になることが確認できます。

```ruby
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]: Ruby の Thread を利用するため、GVL の関係で Ruby レベルでは並列ではなく並行になります。しかし、IO 待ちのケースでは GVL は開放されるため、今回のようなサービス間通信であれば並列に行われます。https://docs.ruby-lang.org/ja/latest/doc/spec=2fthread.html