Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

supabase-jsの.range()は.order()無しで境界の行が重複・欠落する

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

.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(温存)に振り分ける純関数になっている。この referencedfetchReferencedPathscreatives テーブルから 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_atDEFAULT_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

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。