withTenantContextのtx保持中に呼ぶとpool max件で全リクエストが止まる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
withTenantContextでトランザクションを開いたまま、その中でレートリミッタが同じプール(max 10)から別のwithTenantContextを呼ぶ設計だと、同時リクエスト数がプールのmax件に達した瞬間に全員が互いの接続解放を待つ循環ができる。
結論
トランザクションを開いたまま、その中で同じプールへ別のトランザクションを取りに行く設計は、同時リクエスト数がプールのmax件に達した瞬間に全員が互いの接続解放を待つ循環になる。 withTenantContextは1個のPG接続を握るトランザクションで、渡された関数(fn)の実行が終わるまでその接続を解放しない。あるSSEハンドラは、応答を送り終えるまでこのwithTenantContextのtxを保持する設計になっていた。そのfnの中でレート制限(レートリミッタ)のチェックを呼んでおり、そのレートリミッタの実装(CloudSqlRateLimiter)自身が、判定のために同じプールからもう1本withTenantContextを開いていた。プールのmaxは10で、待ちタイムアウトは設定されていない。同時リクエストが10件に達すると、10本とも外側のtxで1本ずつ接続を握ったまま、内側のtxのために11本目の空き接続を待つ状態になり、空く接続は無いので誰も先に進めない。この構成は本番では一度も動いていない。学校単位の日次上限(レート制限)を新規に追加する同じPR(#1401)の中で、マージ前のコードレビューがこの構造を指摘し、マージされる前に直っている。
症状(もしこのまま出ていたら)
生徒向けチャットの質問には、匿名のmagic linkから呼べることによる従量の青天井を防ぐため、学校単位で1日あたりの質問数に上限を設ける機能が追加された(2026-10-01、セキュリティ監査由来)。実装は次の形だった。
// sse-handler.ts(18a5f7b7時点、Reviewer指摘前)
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
await withTenantContext(getDb(), args.tenantContext, async (tx) => {
const result = await executeChat({
tx,
schoolId: args.schoolId,
identity: args.identity,
rawQuestion: question,
piiEntries: [],
contextProvider,
modelClient,
locale,
// 学校単位の日次上限(全インスタンス共有、従量の上限を構造で固定する)。
dailyQuota: getChatDailyQuota(),
});
// ...チャンク送出 + done(assistant 永続化) まで、この tx を保持する
});
} catch { /* ... */ }
},
});
withTenantContextはReadableStreamのstart()内で開かれ、応答のチャンク送出とdone(アシスタントの回答をDBへ永続化する処理)まで保持される。つまりSSEの応答1本につき、1個のPG接続が応答の最後まで占有される設計になっていた。
その内部で呼ばれるexecuteChatは、Gemini呼び出しの直前にdailyQuota.tryAcquireを呼ぶ。
// chat-service.ts(18a5f7b7時点)
// 4.5) 学校単位の日次上限(全インスタンス共有、daily-quota.ts)。ここから先は embedding / Gemini の
// 従量が発生するので、その直前で数える(決定論の範囲外拒否は数えない)。判定できないときは
// Vertex を呼ばずに止める(fail-closed)。
if (dailyQuota) {
let allowed: boolean;
try {
allowed = await dailyQuota.tryAcquire(schoolId, nowMs);
} catch {
return { kind: "rejected", status: 503, reason: "quota_unavailable", message: "..." };
}
if (!allowed) {
return { kind: "rejected", status: 429, reason: "rate_limited_school_daily", message: "..." };
}
}
このdailyQuota.tryAcquireは外側のwithTenantContextのtxの中から呼ばれている。もしこの構成のまま出ていたら、同じ学校から(あるいは複数の学校から合計して)ちょうどプールのmax件(10件)の質問が同時に送られた瞬間、全リクエストが外側のtxで1本ずつ接続を握ったまま、tryAcquireが要求する内側の接続を待ち続け、応答が返らなくなっていたはずである。
原因
tryAcquireの呼び出し先を1段ずつ辿ると、同じプールから独立した2本目のトランザクションを取りに行く構造になっている。
// rate-limit-distributed.ts(18a5f7b7時点)
export class DistributedRateLimiter implements RateLimiter {
async tryAcquire(key: string, nowMs: number): Promise<boolean> {
const windowStartMs = Math.floor(nowMs / this.windowMs) * this.windowMs;
return await this.store.tryAcquireSlot(key, windowStartMs, this.limit);
}
}
DistributedRateLimiter.tryAcquireはストア(store)のtryAcquireSlotへそのまま橋渡しするだけで、自分では接続を持たない。実際に接続を開くのはstoreの実装であるCloudSqlRateLimiter側。
// ai-rate-limit.ts(18a5f7b7時点)
export class CloudSqlRateLimiter implements RateLimitStore {
async tryAcquireSlot(key: string, windowStartMs: number, limit: number): Promise<boolean> {
return await withTenantContext(
this.db,
{ schoolId: key },
async (tx) => {
const rows = await tx.execute<{ count: number }>(sql`
INSERT INTO ai_rate_limit_windows (school_id, window_start_ms, count)
VALUES (${key}::uuid, ${windowStartMs}, 1)
ON CONFLICT (school_id, window_start_ms) DO UPDATE
SET count = ai_rate_limit_windows.count + 1, updated_at = now()
WHERE ai_rate_limit_windows.count < ${limit}
RETURNING count
`);
return rows.length > 0;
},
this.options,
);
}
}
CloudSqlRateLimiter.tryAcquireSlotは、カウンタ行をRLSの文脈でUPSERTするために、自分自身のwithTenantContextを呼んでいる。これはdbクライアント(=SSEハンドラが使っているのと同じKimiterraceDbインスタンス)に対する呼び出しで、同じプールから新しい接続を1本要求する。
プール自体の設定は次の1行だけである。
// client.ts(18a5f7b7時点、72行目)
const sqlClient = postgres(url, { max: 10, onnotice: () => {} });
max: 10以外のオプションは指定されていない。接続待ちのタイムアウト(postgresパッケージのconnect_timeoutやキュー待ちの上限)は設定されておらず、空き接続が出るまで無期限に待つ。
つまり呼び出しの深さは次の3段。
sse-handler.ts withTenantContext(外側tx、応答の最後まで保持)
└ chat-service.ts dailyQuota.tryAcquire(外側txの中で呼ばれる)
└ rate-limit-distributed.ts DistributedRateLimiter.tryAcquire(橋渡しのみ)
└ ai-rate-limit.ts CloudSqlRateLimiter.tryAcquireSlot
└ withTenantContext(内側tx、同じプールから新規に1本)
同時リクエスト数をNとすると、N本の外側txがプールから1本ずつ接続を握る。N=10(プールのmax)になった瞬間、プールの10本すべてが外側txに占有されているため、各リクエストの内側txが要求する「11本目」の接続はどのリクエストからも出てこない。待ちタイムアウトが無いので、これは一時的な遅延ではなく無期限の待ち合いになる。
直し方(マージ前に入った修正)
修正(PR #1401、Reviewer指摘)は、レートリミッタ側(内側のwithTenantContext)には触れていない。直したのはSSEハンドラ側で、クォータ判定を外側のtxが開く前に移した。
// sse-handler.ts(4f34f573時点)
// 2.5) 学校単位の日次上限(daily-quota.ts、全インスタンス共有)。**RLS tx を開く前に**数える
// (tx は回答の最後まで接続を握るので、その中で別接続を取るとプールが詰まる・Reviewer #1401)。
// 拒否は他の拒否と同じく 200 SSE の `error` フレーム 1 枚で返す(クライアントの扱いを揃える)。
const quotaRejection = await checkChatDailyQuota(args.schoolId, question);
if (quotaRejection) {
return errorOnlySse(quotaRejection, args.setCookieHeader ?? null);
}
// 3) SSE ストリーム。RLS tx は start() 内で開き、チャンク送出 + done(assistant 永続化) まで保持する。
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
await withTenantContext(getDb(), args.tenantContext, async (tx) => {
const result = await executeChat({ /* ... */ });
checkChatDailyQuotaは新設の関数で、クォータ判定だけを外側txの外に切り出している。
// daily-quota.ts(4f34f573時点)
export async function checkChatDailyQuota(
schoolId: string,
rawQuestion: string,
opts: { quota?: RateLimiter; nowMs?: number } = {},
): Promise<ChatQuotaRejection | null> {
const v = validateQuestion(rawQuestion);
if (!v.ok) return null;
if (classifyScope(v.question).verdict === "out_of_scope") return null;
const quota = opts.quota ?? getChatDailyQuota();
let allowed: boolean;
try {
allowed = await quota.tryAcquire(schoolId, (opts.nowMs ?? Date.now()) + JST_OFFSET_MS);
} catch {
return { status: 503, reason: "quota_unavailable", message: "..." };
}
if (allowed) return null;
return { status: 429, reason: "rate_limited_school_daily", message: "..." };
}
quota.tryAcquire(=内側のwithTenantContextを開く経路)は、この時点ではまだ外側のtxが開いていない。クォータ判定が確定してから初めて外側のwithTenantContextに入るため、「外側txが接続を握ったまま内側txのための接続を待つ」という経路が構造的に作れなくなる。拒否されたリクエストは、他の拒否(文字数超過・範囲外など)と同じ形のSSE errorフレーム1枚で返す設計に揃えられている。
再発防止
このバグが成立する条件は、「呼び出し元がwithTenantContextを開いたまま、その中で呼ぶ関数が独立したwithTenantContextをさらに開く」という構造が1箇所でもあることだった。修正は該当箇所(クォータ判定)をその構造の外に出す形で解消したが、同じリポジトリのclient.tsには、別の経路向けにこの危険クラスへの注意書きが修正より前から存在している。
// client.ts(18a5f7b7時点、127-133行目、tenantReadFork向けの既存コメント)
// ## 使用規律(プール枯渇・整合性)
//
// - **fork は「葉のクエリ」単位でのみ使う。fork の中で fork を呼ばない**: 外側レーンが接続を
// 保持したまま内側レーンの接続を待つと、プール (`createDbClient` max:10) 満杯時に全リクエストが
// 互いを待つ枯渇デッドロックになりうる。ヘルパへは fork を**引数で渡し**、ヘルパ自身は葉で使う。
これは並列読み取り用のヘルパ(tenantReadFork)に対する規律で、「保持中のtx/レーンの中から、同じプールへさらに接続を取りに行かない」という同じ危険クラスを指している。ただしこの規律の対象はfork系のヘルパに限られており、CloudSqlRateLimiterのようなレートリミッタのストア実装はその対象に含まれていなかった。つまり社内にはこの危険クラス自体への認識と文書化は既にあったが、適用範囲が「保持中のtxの中から同じプールへ新規に接続を取りに行く処理」全体ではなく、forkヘルパという1つの経路に留まっていた。withTenantContextを保持したまま呼ぶ関数を新しく書く側は、呼び出す関数が内部でさらに独立したトランザクション・接続を開いていないかを、ヘルパの種類にかかわらず確認する必要がある。
よくある質問
Q1なぜトランザクションの中で別のトランザクションを開くとデッドロックするのですか?
withTenantContextのtxは1個のPG接続を握ったままfnの実行が終わるまで解放しない。そのfnの中でさらにwithTenantContextを呼ぶと、同じプールから2つ目の接続を要求する。プールのmax件がちょうど同時リクエスト数と同じになると、全リクエストが外側のtxで1本ずつ接続を握りながら、内側のtxのために11本目を待つ状態になり、誰も先に進めなくなる。
Q2このデッドロックは本番で実際に起きたのですか?
いいえ。修正コミットの記録によれば、学校単位の日次上限(レート制限)という機能を新規に追加する同じPR(#1401)の中で、マージ前のコードレビューで「このままでは同時10件で詰まる」と指摘され、マージされる前に直っている。本番でこの構成のコードが動いたことは無い。
Q3直し方はレートリミッタの実装を変えることだったのですか?
いいえ。CloudSqlRateLimiter側(内側のwithTenantContext)は変更されていない。直したのはSSEハンドラ側で、クォータ判定(checkChatDailyQuota)の呼び出し位置を、外側のwithTenantContextを開くより前に移しただけ。クォータ判定が外側txの外で完了するため、内側のwithTenantContextがプールへ要求する2本目の接続と外側txが競合する経路自体が無くなる。
Q4このプール枯渇の危険は他の箇所にもあったのですか?
同じリポジトリのclient.tsには、読み取り専用の並列クエリ用ヘルパ(tenantReadFork)向けに「fork の中で fork を呼ばない」という使用規律が、この修正より前から文書化されていた。これは行レベルの別経路での同じ危険(プール満杯時の枯渇デッドロック)に対する既存の注意書きで、今回CloudSqlRateLimiterが詰まった経路はその規律の対象(fork系ヘルパ)に入っていなかった。つまり社内にはこの危険のクラス自体への認識はあったが、ルールの適用範囲が今回の経路まで及んでいなかった。
確認した環境
- PostgreSQL(Cloud SQL)。接続プールは postgres(porsager/postgres)の createDbClient が max: 10 で生成
- postgres(porsager/postgres)^3.4.5 / drizzle-orm ^0.45.2(packages/db/package.json、該当コミット時点)
- 2026-10-01 同日中に導入・レビュー指摘・修正(PR #1401、マージ前)
この記事の根拠
- TypeScriptファイル 174〜186行目コミット 18a5f7b
- TypeScriptファイル 349〜371行目コミット 18a5f7b
- TypeScriptファイル 56〜86行目コミット 18a5f7b
- TypeScriptファイル 29〜63行目コミット 18a5f7b
- TypeScriptファイル 72〜80行目コミット 18a5f7b
- TypeScriptファイル 127〜133行目コミット 18a5f7b
- TypeScriptファイル 175〜196行目コミット 4f34f57
- TypeScriptファイル 75〜100行目コミット 4f34f57
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。