supabase-jsの.range()は.order()無しで境界の行が重複・欠落する
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
.range()だけのページングは、順序を固定する.order()が無いとページ境界で行が重複・欠落しうるため、参照集合が1件でも欠ければ生きた素材が「未参照」に誤判定されて削除対象になる。
結論
.range() だけで OFFSET/LIMIT ページングし、.order() で順序を固定していないと、ページ境界で行が重複したり欠落したりしうる。 ある参照集合の取得コードはこの形でページングしており、対象カラムに unique 制約が無く順序キーに使えなかったため、参照が1件でも欠けると「参照されていない」と誤判定されるオブジェクトが出かねない状態だった。直し方は、一意な主キー id で明示的に .order() してから .range() すること。
症状になりうる状態
対象は Supabase Storage の孤児オブジェクト掃除ジョブ。仕組みはこうなっている。広告素材は「ブラウザからストレージへ署名付き URL で直アップロード → finalizeCreative で DB に登録」という2段階になっており、アップロードだけ成功して DB 登録に到達しなかった場合(ブラウザ離脱・通信エラー)、creatives.file_path から参照されないオブジェクトがバケットに残る。これを定期的に回収するのが掃除ジョブで、DEFAULT_ORPHAN_HOURS = 24(既定の年齢しきい値)より古い未参照オブジェクトだけを削除対象にする。しきい値の下限は MIN_ORPHAN_HOURS = 6、1回の実行で削除する最大件数は MAX_DELETE_PER_RUN = 500 に上限化されている。
削除対象を決める partitionOrphans は、ストレージ上のオブジェクトを1件ずつ見て、referenced.has(o.path) が真なら「正規の素材」として除外し、そうでなければ created_at としきい値を比べて eligible(削除対象)か tooRecent(温存)に振り分ける純関数になっている。この referenced は fetchReferencedPaths が creatives テーブルから file_path を全件ページングして作る Set で、1000件(FILE_PATH_PAGE)ずつ .range() で取得していた。
修正前のコードは、この取得に .order() を挟んでいなかった。
export async function fetchReferencedPaths(
admin: SupabaseClient,
errors: string[]
): Promise<{ paths: Set<string>; ok: boolean }> {
const set = new Set<string>();
let from = 0;
for (;;) {
const { data, error } = await admin
.from("creatives")
.select("file_path")
.not("file_path", "is", null)
.range(from, from + FILE_PATH_PAGE - 1);
if (error) {
errors.push(`creatives.file_path: ${error.message}`);
return { paths: set, ok: false };
}
// ...
}
}
file_path に unique 制約は無い。順序を指定しないまま .range() でページを分けていると、ページ境界の行がどちらのページにも出る(重複)か、どちらのページにも出ない(欠落)かが起こりうる。referenced に1件でも欠けが生じれば、それに対応するストレージオブジェクトは partitionOrphans で「未参照」扱いになり、created_at が DEFAULT_ORPHAN_HOURS(24時間)を超えていれば削除対象の eligible に入る。
原因
.range() は PostgREST 経由で PostgreSQL の OFFSET / LIMIT に相当する。ORDER BY を指定しない SELECT の行順序はデータベース側の自由であり、同じクエリを違う OFFSET で2回投げたときに同じ順序が返る保証は無い。ページングのように同じ集合を複数回に分けて問い合わせる用途では、この「毎回同じ順序」という前提が暗黙に必要になるが、.order() を指定しない限りそれは満たされない。
このコードは1ページでも DB エラーが起きれば ok=false を返し、呼び出し元の掃除ジョブ本体(sweepCreativeOrphans)は削除そのものをスキップする fail-closed 設計になっていた。だがこの仕組みが検知できるのは明示的なエラーだけで、順序不定によって行が静かに重複・欠落するケースは error が返らないぶん、この fail-closed の対象外だった。
直し方
一意な主キー id で .order("id", { ascending: true }) を先に固定してから .range() するように直した。
const { data, error } = await admin
.from("creatives")
.select("file_path")
.not("file_path", "is", null)
// 一意な主キー id で全順序を固定してからページング。ORDER BY なしの OFFSET/LIMIT は
// ページ境界で行が重複/欠落しうる(file_path は unique でないので順序キーに使えない)。
// 参照パスが 1 件でも欠けると生きた素材が「未参照」に誤判定され削除されるため必須。
.order("id", { ascending: true })
.range(from, from + FILE_PATH_PAGE - 1);
id は主キーで一意なので、同じ .order("id") を指定する限り、どのページ問い合わせも常に同じ全順序の部分集合を返す。これでページ境界の行が重複・欠落する余地が無くなり、referenced は常に完全な集合になる。
再発防止
同じコミットで、テストのモックチェーンに order: () => chain という no-op が追加されている。
const chain = {
select: () => chain,
not: () => chain,
// 本番は .order("id") で全順序を固定してからページングする(ページ境界の欠落防止)。
// モックではスライス順に影響しないため no-op。
order: () => chain,
range: async (a: number, b: number) => {
const slice = (opts.filePaths ?? []).slice(a, b + 1).map((fp) => ({ file_path: fp }));
return { data: slice, error: null };
},
};
このモックは .order() を呼ばれても何もせず、range() は渡された配列をそのまま slice して返す。つまりテストが確かめているのは「コードが .order() というメソッドを呼んでもチェーンが壊れない」ことだけで、実際の DB が順序不定のときに起きる重複・欠落そのものは再現していない。同じ関数に今後別の変更が入り、再び .order() が外れたとしても、このテストは検知できない。
この修正は実装レビューで見つかり、同日中に直っている。根拠のコミットメッセージにも本番で実際に削除が起きたという記録は無く、起きる前に塞いだ形になる。同じ関数の docstring は「参照集合が不完全なら削除は絶対に走らせてはならない」と明記しており、fail-closed の意図そのものは最初から正しかった。抜けていたのは、その意図を守るために .range() 単体では順序が保証されないという前提のほうだった。
よくある質問
Q1なぜ.order()が無い.range()だけのページングだと行が重複・欠落するのですか?
対象のfile_pathカラムにはunique制約が無く、順序を明示しないOFFSET/LIMIT(.range())はページごとの行順序を保証しない。1000件区切りでページングする間に順序が変われば、境界の行が次ページに重複して出るか、逆に一度も出ないまま抜け落ちる。
Q2本番で実際に素材が削除された事故は起きたのですか?
根拠のコミットメッセージには実装レビューで発見し同日中に修正したことが書かれているだけで、本番で実際に削除が起きたという記録は無い。
Q3なぜidをorderのキーに選んだのですか?
file_pathカラムにunique制約が無く順序キーに使えないため、一意な主キーであるidを.order("id",{ascending:true})で固定してから.range()するように直した。
Q4参照集合の取得中にDBエラーが起きたらどうなりますか?
1ページでも取得に失敗すると即座にok=falseを返し、呼び出し元のsweep処理は削除そのものをスキップする(fail-closed設計)。
Q5この修正で追加されたテストは、重複・欠落の再現を検証していますか?
していない。追加されたのはモックのチェーンにorder: () => chainというno-opを足しただけで、モックのrange()は配列をそのままsliceして返すため、順序が無くても重複・欠落は再現されない。
確認した環境
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / @supabase/ssr ^0.10.3
- 2026-07-13 の実装レビューで発見・同日中に修正(本番での削除事故の記録は無し)
この記事の根拠
- TypeScriptファイル 1〜47行目コミット 4f920cc
- TypeScriptファイル 167〜181行目コミット 6d414ec
- TypeScriptファイル 167〜185行目コミット 4f920cc
- TypeScriptファイル 197〜225行目コミット 4f920cc
- TypeScriptファイル 71〜82行目コミット 4f920cc
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。