Supabase × postgres-js を使うなら prepare: false に設定しよう!!!
Supabase の Supavisor(Transaction Mode)と postgres-js を組み合わせるとき、prepare: false を設定しないと prepared statement does not exist エラーが確率的に発生する。
結論
Supabase の Supavisor(Transaction Mode)と postgres-js を組み合わせて使う場合、prepare: false の設定は必須です。
import postgres from "postgres";
const client = postgres(DATABASE_URL, {
ssl: { rejectUnauthorized: false },
prepare: false, // ← これがないと問題が起きる
});
Drizzle ORM を使っている場合も同様です。Drizzle の公式ドキュメントにも記載されています。
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
const client = postgres(DATABASE_URL, { prepare: false });
const db = drizzle(client);
この設定を入れないと何が起きるのか、順を追って説明します。
この設定がないとどうなるか
確率的にエラーが発生する
PostgreSQLのログにこんなエラーが出るようになります。
ERROR: prepared statement "hu8kbprg4t2" does not exist
このエラーは毎回出るわけではなく、出たり出なかったりします。ローカル環境では再現しないのに本番環境だけで出る、というパターンになりがちです。
なぜこのエラーが起きるのか
postgres-js の prepared statement キャッシュ
postgres-js は、パフォーマンス最適化のためにデフォルトで prepared statement を使います。
初回実行時に Parse(prepared statement の作成)→ Bind → Execute を送り、2回目以降は Parse をスキップして Bind → Execute だけを送る仕組みです。同じSQLを何度も実行するときに、パース処理を省略できて速い。
これ自体は普通の最適化なのですが、接続プーラーの Transaction Mode との相性が最悪でした。
Supavisor の Transaction Mode
Supabase の接続プーラーである Supavisor は、デフォルトで Transaction Mode(ポート6543)で動作します。
Transaction Mode では、クライアントのトランザクションが終わるたびにバックエンドのPostgreSQL接続がプールに返却されて、次のトランザクションでは別のバックエンド接続が割り当てられる可能性があります。
ここが問題です。prepared statement は PostgreSQL のセッション(接続)レベルのオブジェクトなので、接続が変わるとクライアントがキャッシュしている prepared statement は存在しません。
エラーが発生するシナリオ
つまり、こういうことが起きます:
- クライアントがリクエストAで
INSERT INTO users ...を実行 → prepared statements1が接続Xに作成される - リクエストA完了 → 接続Xがプールに返却される
- クライアントがリクエストBを開始 → 接続Yが割り当てられる
- postgres-js は
s1がキャッシュにあるのでParseをスキップしてBind/Executeを送る - 接続Yには
s1が存在しない →prepared statement "s1" does not exist
確率的に発生するのは、たまたま同じ接続が再割り当てされれば成功するからです。接続プールのサイズや同時接続数によって再現率が変わります。
パフォーマンスへの影響は?
「prepared statement を無効化したらパフォーマンスが落ちるのでは?」と心配になるかもしれませんが、Transaction Mode を使っている時点で prepared statement のキャッシュはまともに機能していません。接続が毎回変わる可能性がある以上、キャッシュヒットは運頼みです。
むしろ、キャッシュミスしたときのエラーとリトライのオーバーヘッドの方が大きい。prepare: false にすることでこのオーバーヘッドがなくなるので、Transaction Mode 環境では実質的にデメリットなしです。
Session Mode(ポート5432)なら問題ない
Supavisor を Session Mode(ポート5432)で使う場合は、クライアントとバックエンド接続が1対1で固定されるので、この問題は発生しません。prepared statement も正しくキャッシュされます。
ただし、Session Mode は同時接続数がバックエンドのPostgreSQL接続数に制限されるので、接続数の多い環境では Transaction Mode が選ばれることが多いと思います。その場合は prepare: false が必須です。
Supabase のドキュメントにも書いてある(が、気づきにくい)
実はこの問題、Supabase の公式ドキュメントにも記載されています。
Transaction mode does not support prepared statements.
また、Supavisor のFAQ にも同様の記述があります。
ただ、postgres-js のデフォルトが prepare: true なので、意識していないとそのまま使ってしまいがちです。GitHub の Discussion でも同じ問題に遭遇している人がいて、もう少し目立つ場所に書いてほしいな、という声がありました。
Cloud Run や Cloudflare Workers のようなサーバーレス環境では接続プールを使いたくなりますが、Transaction Mode を選んだ場合はここがハマりポイントになります。ローカルで再現しにくいのも厄介なので、Supabase を使い始めたタイミングで prepare: false を設定しておくのがおすすめです。