♾️

GraphQL でネストしたクエリを書いたら Apollo Client が無限に計算し続けるようになった話

2023/10/20に公開

人の生は有限なので実質無限

おはようございます。 株式会社 dinii の Platform Team で SWE をしている whatasoda です。dinii では GraphQL を使っているのですが、ある日ネストしたクエリがきっかけで Apollo Client がページをフリーズさせるほどに時間のかかる計算を実行する事態に遭遇しました。この記事ではその原因と dinii での対処について説明します。

TL;DR

  • Apollo Client でキャッシュを利用しているとクエリの形状に基づいたキャッシュの書き込みを行うためにレスポンスデータの検証を行う
  • 検証はデータ取得の度に同期的に行われ、データの量やクエリの複雑度などによっては膨大な時間を要する
  • 一度に取得するデータの量を調整するかクエリの複雑度を調整することで改善は見込めるが、どちらも難しい場合は Apollo Client にパッチを当てるなどして検証の処理を飛ばすようにするしかない

なにが起きるのか

百聞は一見に如かず。デモを用意しています。(※このデモは意図的にページが一定時間フリーズするように実装しています。試す場合は自己責任でお願いします。)

https://apollo-client-heavy-validation-demo.whatasoda.me/

https://github.com/whatasoda/apollo-client-heavy-validation-demo

デモの内容は単純です。内容の同じ Sample01、Sample02、Sample03 というクエリを発行するとそれぞれに事前に定義してある数のデータが返ってきます。今回はデモなので msw を使ってバックエンドアプリケーションなしでも該当の事象を再現できるようにしました。

query Sample0X {
  article {
    id
    title
    paragraph {
      id
      text
    }
  }
}

article と paragraph がそれぞれ配列でいずれのクエリも 100 件の article を返しますが、 1 article あたりの paragraph の数に差を持たせており、 Sample01 では 100 paragraph、Sample03 では 10000 paragraph のデータを返します。

import { graphql } from "msw";
import { v4 as uuid } from "uuid";

const createData = ({
  numArticle,
  numParagraphPerArticle,
}: {
  numArticle: number;
  numParagraphPerArticle: number;
}) => ({
  article: Array.from({ length: numArticle }).map(() => ({
    id: uuid(),
    title: "Article",
    paragraph: Array.from({ length: numParagraphPerArticle }).map(() => ({
      id: uuid(),
      text: "sample",
    })),
  })),
});

const sample01 = createData({ numArticle: 100, numParagraphPerArticle: 100 });
const sample02 = createData({ numArticle: 100, numParagraphPerArticle: 1000 });
const sample03 = createData({ numArticle: 100, numParagraphPerArticle: 10000 });

export const handlers = [
  graphql.query("Sample01", (_req, res, ctx) => res(ctx.data(sample01))),
  graphql.query("Sample02", (_req, res, ctx) => res(ctx.data(sample02))),
  graphql.query("Sample03", (_req, res, ctx) => res(ctx.data(sample03))),
];

早速ですが実際にデモを触ってみましょう。なお、デモページではフリーズしたことを視覚的にわかりやすくするために 10ms ごとに Date.now() を setState して表示する Clock というコンポーネントと、CSS の :hover で色の変わるただの四角い div である Hover というコンポーネントを用意しています。

Sample 01 を押すと特にフリーズらしいものはありませんが、Sample 02 では Clock の更新が止まり、 Hover にマウスカーソルを当てても色が変わらなくなりました。Sample 02 の場合は数秒で元に戻りますが、 Sample 03 を押すと10秒以上経っても元に戻る気配はありません。

ちなみに、キャッシュを利用しない場合だとこのフリーズは発生しません。Sample 03 ですこしフリーズしているように見えますが、これは msw 側の処理によるものが大きいです。

このように、キャッシュを有効にした状態の Apollo Client で大量のデータを受け取ろうとするとページ上での他のあらゆる処理をブロックしてしまいます。

では、フリーズしている間は一体何が起きているのでしょうか。 Developer Tool でパフォーマンス計測をしてみましょう。手元で実行する場合は Sample02 までの計測で十分な結果を確認できます。(Sample03 を試すと無限の時間がかかってしまうのでおすすめしません)

結果を見てみるとかなりの回数のループらしき処理があり、Layout などの描画を行う処理がブロックされていることがわかります。このスクリーンショットでは 5000ms 分ぐらいの範囲を切り取っていますが、後続にも同様の処理が続いています。

ここで得られた trace から確認できる関数名などを元に調査をしていくと、この処理は Apollo Client の内部で実行されている計算であることを突き止めることができます。具体的には このあたりこのあたり が該当する箇所のソースコードです。

なぜ起きるのか

フリーズしている間、どの処理が行われているのかということはわかりました。では、なぜその処理がこんなにもたくさん実行されているのでしょうか。それはもう気合でコードを読んでいくしかありません。

とはいえ無策にコードを読むのはなかなか骨が折れます。詳細は省きますが、私は node_modules/@apollo/client 内の JavaScript ファイルに console.log を仕込んだり、Developer Tool の Source タブでそのログの出処を探して Breakpoint を仕込むなどして処理を一時停止しながら何が起きているのかを探っていきました。

長きにわたる闘いの末、どうやらこれらの処理は受け取ったデータをキャッシュに保存する際にクエリドキュメントオブジェクトの形状通りに保存するための検証のような処理であるようでした。この検証はクエリのフィールド単位まで行われるため、クエリがネストしている場合にはネストの末端までを再帰的な処理が走るようです。

このように、場合によってはかなりの計算コストを必要とする処理のすべてが一つの同期的な処理の呼び出しの中で実行されるため、他の処理をすべてブロックしてフリーズしたかのような挙動になってしまったようです。

一つ一つのフィールドまで確認を行うため、計算コストは「検証するフィールドの数」にざっくり比例すると言えそうです。検証が必要なフィールドの数は「受け取るデータの数」と「クエリの複雑度」の掛け算でざっくりとした大きさを知ることができそうなので、データ数が多かったりクエリの形状が複雑だとキャッシュの書き込み時の検証によりアプリケーションがフリーズするリスクが高まると言えそうです。今回の例では paragraph の中には id と text があるのでざっくりと paragraph の数 × 2回検証するといったイメージです。

query Sample0X {
  article {
    id
    title
    paragraph {
      id
      text
    }
  }
}

ここで注意が必要なのが、「データのサイズ」ではなく「データの数」という表現をしている点です。計算コストはあくまで検証するフィールドの数に対応しているものなので、text の中身が長さ10の文字列である1000件のデータと、text の中身が1000である10件のデータとではデータのサイズ自体がほぼ同じでも前者のほうがキャッシュ書き込み時の計算コストが高くなります。

さて、この記事のタイトルではネストのあるクエリが原因であるかのような表現をしていますが決してそうではなく、あくまでネストのあるクエリでは発生しやすいというだけに過ぎません。ネストが深いフィールドに対する検証数は親オブジェクトが配列だった場合親オブジェクトの数との掛け算になるため、計算コストが高くなりやすいというわけです。

以下の2点を満たしているとキャッシュの書き込み時の検証に大きな計算コストがかかる可能性があります。

  • GraphQL クエリの形状が複雑である
  • 単純に受け取るデータの数が多い

逆に言えばこれらの条件に当てはまらないようにアプリケーションを作っていればこの検証処理によるフリーズに悩まされることはないと言えるでしょう。例えばページネーションを実装して一度に取得するデータの数を減らせば、検証処理自体は依然として実行され他の処理をブロックしてフリーズを引き起こしますが、必要なループ数が少ないためユーザー体験上問題にならない短い時間に留めることができます。あるいは、これはベストプラクティスではないかもしれませんが、オブジェクトの一部を JSON Text にまとめて返すことでも検証のループの数を減らすことができます。

しかし、 dinii ではこれらの対策を取れない(取りたくない)事情が存在しています。

まず、ページネーションについてです。dinii が開発しているプロダクトではメニューなどのマスターデータを扱いたいケースが多々あります。そのようなケースでは結局最後には全件取得をするのですが、データのサイズ(バイト数)的には大したことがないためできれば一度に取得しきってしまいたい気持ちがあります。また、ページネーションの導入は多少ではあるものの実装の複雑度を上げてしまう可能性があるため、やらなくていいならやらないほうが嬉しいです。

dinii でなぜネストのあるクエリが必要なのか

dinii では Hasura という PostgreSQL のスキーマから簡単な CRUD が可能な GraphQL エンドポイントを作成してくれるミドルウェアを採用しています。Hasura には様々な機能がありますが、その中でも Relationship という機能は非常に重宝しており、ネストした GraphQL のクエリを記述するだけで複数のテーブルのデータをテーブル同士の関連に即した形で簡単に取得できます。

先のデモにあったような article と paragraph を例に出すと、article と paragraph はそれぞれ別のテーブルで管理しておき、paragraph が article の id を外部キーとして持つことで article に紐づく paragraph を以下のようなクエリでまとめて取得することができます。

query Sample0X {
  article {
    id
    title
    paragraph {
      id
      text
    }
  }
}

この Relationship の機能は非常に便利なのですが、クエリをネストさせて記述する必要があるため、dinii のコードベースではネストしたクエリがどうしても増えてしまいます。

dinii ではどう解決しているのか

Apollo Client はフィールド単位で細かな検証をしてくれていますが、正直そこまで細かいことを気にしないことのほうが多いのではないでしょうか。別に検証が必要ないのであればスキップしてしまいましょう。受け取ったデータがクエリの形状と異なるリスクは存在していますが、Hasura が返すデータを信用すればそのリスクは無視できます。

検証をスキップさせるためには Apollo Client の内部の処理に手を加える必要があります。今回は patch-package というツールを用いて node_modules 内の Apollo Client のコードに手を加えることにしました。ゴリ押しです。

前述した通り、Apollo Client はクエリの末端のフィールド一つ一つまで検証を行っていきますが、逆に言えば末端のフィールドに到達すればその先で追加のループを発行することはないと言えます。先程、レスポンスの一部を一つの JSON Text として返すようにすれば計算コストを削減できるという言及をしました。Hasura を使っているので実際に JSON Text として返すことはできませんが、Apollo Client 側でオブジェクトをプリミティブな値として認識させることはできるかもしれません。

実際のパッチがこちらです。

https://github.com/whatasoda/apollo-client-heavy-validation-demo/blob/36f7f04cca40b4ce5f67c86edde3e0aa006dd12e/patches/@apollo+client+3.8.6.patch#L17

patch-package を利用する場合、本来は node_modules の中を直接いじった上で patch-package のコマンドを実行することでパッチファイルを作成するのですが、今回は Apollo Client のソースコードをいじって手元でビルドしたものを node_modules 内のものと置き換えてからパッチファイルを作成しました。

GraphQL には directive という機能があります。このパッチでは asScalar というカスタムの directive を用い、 asScalar を持つものは下層のクエリについての検証を省くようにしました。

query Sample0X {
  article @asScalar {
    id
    title
    paragraph {
      id
      text
    }
  }
}

デモにもパッチを適用しており、asScalar を使ったクエリを利用する Fetch Sample 0X as scalar というボタンを配置しています。これらはキャッシュを有効にしているにも関わらずかなり早いスピードで処理を終えています。

このパッチは Apollo Client の内部的な処理にこそ変更は加えていますが、GraphQL のバックエンド側では何も変更する必要はなく、クライアントアプリケーションでのクエリの書き方などにも大した変わりはありません。機能の開発者はデータの数が多くて検証の計算コストが高くなりそうな箇所におもむろに @asScalar をつけておけばキャッシュの書き込み時の不要な検証をスキップできるようになります。

まとめ & 宣伝

今回は Apollo Client でキャッシュを利用している場合に遭遇し得るパフォーマンス上の問題について説明しましたが、そもそもキャッシュが本当に必要な場面ってそんなにあるんだっけという話もあるかと思います。dinii はオフライン状況下でも会計や注文ができるような POS レジのアプリケーションを React Native を使って開発しており、メニューなどのマスターデータを永続化した Apollo Client のキャッシュで保持するようにしていて、ゴリゴリに頼りにしています。今回の問題はそういった開発においてキャッシュと向き合っていく中で出会った問題でした。

dinii はこれからも飲食業界が抱える課題に向き合いながら新たな価値を発見していき、飲食という文化をもっと楽しくて面白いものにしていきます。そんな dinii では一緒に働いてくださる方を募集しています!(募集の詳細はこちらから

Discussion