Blogへ戻る

Language: 日本語

「Promise.all なのに直列実行?」 Prisma のトランザクション内でクエリがタイムアウトする罠と静的検知設計

Node.js / TypeScript のコードで複数の非同期処理を並列化するとき、誰もが当たり前のように `Promise.all` を使います。

Node.js / TypeScript のコードで複数の非同期処理を並列化するとき、誰もが当たり前のように Promise.all を使います。

しかし、Prisma のトランザクション($transaction や interactive transaction)の内部で Promise.all を書いたとき、「見た目は並列に並んでいるのに、裏側では 1 本のコネクション上で完全に直列実行され、タイムアウトエラー(P2028)で障害になる」 という致命的な落とし穴が存在します。

PostgreSQL の RLS(行レベルセキュリティ)パフォーマンス最適化(『その RLS、1行ごとに関数をループしていませんか? サブクエリと InitPlan で解決する行単位評価の罠』)を進める中で、この「直列化の罠」による本番障害と遭遇しました。

この記事では、Prisma トランザクションがコネクションを占有する仕組みと、クエリ単位での tx 分割手法、そして ESLint と ast-grep による二層の静的検知設計 を解説します。

得られること

  • Prisma の interactive transaction 内で Promise.all が並列にならない物理的な理由がわかる
  • 重い集計クエリをクエリ単位の scoped tx に分割し、本当の並列化と接続プール拡張を行う手順が学べる
  • 「集約」と「分割」という一見矛盾する 2 つの静的解析ルールを使い分ける設計指針が身につく
  • ESLint flat config の上書きルール罠を回避しつつ、ast-grep を CI の hard gate に据える二層検知の実装がわかる!

1. 1 コネクションを占有する interactive transaction の罠

アノテーション基盤の統計集計エンドポイントで、Prisma の P2028: Transaction timed out(デフォルト 5 秒)が断続的に発生し、HTTP 500 エラーを返す障害が起きていました。

該当箇所のコードは、次のように 5 本の集計クエリを Promise.all で並べていました。

// 改善前のコード(イメージ)
const [r1, r2, r3, r4, r5] = await this.prisma.withCurrentRlsScope(async (tx) =>
  Promise.all([
    tx.task.count(),
    tx.assignment.groupBy({ /* ... */ }),
    tx.metric.findMany({ /* ... */ }),
    tx.log.count({ /* ... */ }),
    tx.summary.findMany({ /* ... */ }),
  ]),
);

withCurrentRlsScope は、RLS の認可スコープ(set_config)をセットしたトランザクション内でクエリを安全に実行するためのラッパーです。

一見すると 5 本のクエリが並列実行されているように見えます。しかし、Prisma の interactive transaction はデータベースの「1 コネクション」を丸ごと占有 します。

【 トランザクション内部での直列実行 】
1 本の DB コネクション (tx)
   ├─ [1] tx.task.count() (1.2 秒)
   ├─ [2] tx.assignment.groupBy() (1.5 秒)
   ├─ [3] tx.metric.findMany() (0.9 秒)
   ├─ [4] tx.log.count() (1.1 秒)
   └─ [5] tx.summary.findMany() (0.8 秒)
─────────────────────────────────────────────
合計所要時間: 5.5 秒 ──→ 💥 5 秒のタイムアウトを超過して P2028 発生!

JavaScript 上では Promise.all で同時にリクエストを発行しても、送信先のコネクションが 1 本しかないため、データベース側では 1 つずつ順番に直列実行されます。その結果、各クエリの実行時間がすべて足し算され、トランザクションのタイムアウト制限(5 秒)を突き抜けていました。

2. クエリごとの scoped tx 分割と接続プールの拡張

解決策は、Promise.all をトランザクションの外側に出し、クエリごとに独立した scoped tx を立てる」 ことです。

// 改善後のコード(イメージ)
const [r1, r2, r3, r4, r5] = await Promise.all([
  this.prisma.withCurrentRlsScope((tx) => tx.task.count()),
  this.prisma.withCurrentRlsScope((tx) => tx.assignment.groupBy({ /* ... */ })),
  this.prisma.withCurrentRlsScope((tx) => tx.metric.findMany({ /* ... */ })),
  this.prisma.withCurrentRlsScope((tx) => tx.log.count({ /* ... */ })),
  this.prisma.withCurrentRlsScope((tx) => tx.summary.findMany({ /* ... */ })),
]);

各クエリが個別にコネクションを借りて RLS スコープ設定とクエリを実行するため、データベース側で本当に 5 本のクエリが並列実行 されます。全体の所要時間は「最も遅いクエリ 1 本分(約 1.5 秒)」に短縮され、P2028 エラーは完全に消滅しました。

接続プールのサイジング

並列化によって同時に消費されるコネクション数が 1 本から 5 本へ増えるため、アプリケーション側の接続プール設定を DB_CONNECTION_LIMIT=30 へ引き上げました。

環境変更前変更後Cloud SQL 上限(max_connections)本番接続ピーク実測
dev / stg1030400 以上-
本番環境1530400 以上18 接続

Cloud SQL の上限(400+)に対して実測ピークは 18 接続であり、全コンテナの最大起動数を考慮しても十分な余裕を持った安全なサイジングです。

3. 「集約」と「分割」の 2 つの静的解析ルールの調和

実は、このリポジトリには以前から rls-promise-all-not-scoped という ast-grep ルールが存在していました。

  • rls-promise-all-not-scoped: tx を介さずに未スコープのクエリをバラバラに Promise.all しているコードに対し、「1 つの scoped tx に集約せよ」と警告するルール。

今回新たに追加した no-promise-all-in-prisma-tx は、tx の内部にある Promise.all を検知して「クエリごとの tx に分割せよ」と警告します。

一見すると正反対のルールに見えますが、「クエリの重さ」によって正解が異なる ため、両者は完全に両立します。

【 クエリの特性に応じた使い分け基準 】
1. 重い読み取りクエリ(集計・検索等):
   合計時間が tx タイムアウトに近づくため、クエリ単位で分割して外側で Promise.all する
2. 軽い読み取りクエリ(数ミリ秒で終わる単純な SELECT 等):
   コネクションの浪費と BEGIN/COMMIT オーバーヘッドを避けるため、1 つの tx に集約して逐次 await する
ルール名検知対象修正の方向Severity
rls-promise-all-not-scopedtx を使わず未スコープのクエリを Promise.all している1 つの scoped tx に集約する(軽いクエリ向け)warning
no-promise-all-in-prisma-txtx コールバック内に Promise.all がネストしているクエリごとの tx に分割する(重いクエリ向け)error (CI hard gate)

4. ESLint と ast-grep による二層の静的検知設計

再発を確実に防ぐため、ESLint と ast-grep の二層防御を構築しました。

  1. ESLint (warning): IDE や pre-commit で、開発者がコードを書いた瞬間に警告を出す
  2. ast-grep (error): CI の hard gate として、違反があればビルドを即座に落とす

ESLint flat config の上書き罠

ESLint のセレクタは、tx を作成する 4 つのメソッド($transaction, withCurrentRlsScope, withTenantScope, withListScope)の直下にある Promise.all を正確に捕捉します。

CallExpression[callee.property.name=/^(\$transaction|withCurrentRlsScope|withTenantScope|withListScope)$/] > :function CallExpression[callee.object.name='Promise'][callee.property.name=/^(all|allSettled)$/]

ここで注意が必要だったのが、ESLint flat config の挙動です。 同じ対象ファイルに対して新しい no-restricted-syntax: error ブロックを別定義で追加すると、後勝ちで既存の no-restricted-syntaxnew Loggerprocess.stdout.write の禁止設定)が丸ごと上書きされて消滅する という罠がありました。

そのため、既存の warn ブロックのセレクタ配列に追記する形で安全にルールをマージし、CI での拒否(hard gate)は ast-grep 側に一本化しました。

5. 静的検知が届かないケースの処置

セレクタは tx コールバックに「直接ネスト」した呼び出しを検査するため、tx オブジェクトを別関数に引数で渡してその中で Promise.all を呼ぶような間接パターンは検知をすり抜けます。

コードベースを網羅的に探索したところ、該当するヘルパー関数が 2 箇所見つかりました。 これらはいずれも数件の ID を取得する極めて軽い読み取りだったため、tx を分割するのではなく Promise.all を単なる逐次 await に書き換え ました。1 本のコネクション内ではどのみち直列実行されるため、不要な並列構文を排除してコードの意図を正確に合わせた形です。

まとめ: クエリが発行される「物理コネクション数」を意識する

Promise.all は便利な構文ですが、「物理的な通信コネクションが 1 本に束ねられている環境では並列化されない」 という原則を忘れてはなりません。

  1. tx 内の Promise.all は直列: interactive transaction 内ではクエリの実行時間がすべて足し算される
  2. 重いクエリは外で並列化: クエリごとに scoped tx を立て、外側で Promise.all を回す
  3. 接続プールを適切に確保: 並列化による同時接続数増加に耐えられるよう DB_CONNECTION_LIMIT を実測に基づいてサイジングする
  4. 二層の静的解析で守る: ESLint による即時警告と ast-grep による CI ゲートで再発を機械的に封じる

構文の見た目だけに惑わされず、データベースとの物理的な接続境界を意識した設計を行うことが、高負荷に耐えるバックエンドを築く基本です。

参考リンク