はじめに
実運用しているアプリで、ある日突然、農薬情報が表示されなくなりました。
以前の記事(⑭-1)で「ログが増えたら対処が必要」と書いていましたが、想定していたのは「全件取得が重くなる」問題でした。実際に起きたのは少し違います。
データはデータベースにちゃんと存在するのに、画面には表示されない。
原因はSupabaseの取得件数上限でした。
しかも、一度対策したつもりだったのですが、その後さらにデータが増えたことで、対策が不十分だったことまで分かりました。
症状①:農薬情報が表示されない
最初に起きたのは作業履歴でした。
防除記録を確認すると、ある日の記録には使用した農薬が表示されているのに、別の日の防除記録には農薬情報が出てきません。
同じページ、同じ防除記録なのに、なぜ一部だけ表示されないのか。
原因調査
まずデータベースを確認しました。
sql
SELECT * FROM cult_log_pesticides
WHERE log_id IN (該当のログID);
データはちゃんと入っています。DBの問題ではない。
次にコードを疑いました。直前にコードを変更していたので、その影響かと思ったのですが、変更内容を確認するとグループキーの生成ロジックを変えただけで、農薬の取得処理には触れていませんでした。
では何が原因なのか。デバッグのためにサーバー側のコードにログを仕込みました。
typescript
console.log("logIds count:", logIds.length);
console.log("pesticidesData count:", pesticidesData?.length);
Vercelのログを確認すると:
logIds count: 863
pesticidesData count: 1000
これで原因が確定しました。
Supabaseには1回の取得件数に上限がある
Supabaseでは、API経由で1回に取得できる行数に上限があります。
私の環境では、この上限が1000件でした。
問題になった処理は、ざっくり書くとこうです。
作業ログ(cult_logs)を全件取得
↓
ログIDを集めてリスト化(863件)
↓
そのIDに紐づく農薬データ(cult_log_pesticides)を一括取得
↓
ここで1000件上限に引っかかる
ここで少し分かりにくかったのが、
作業記録が1000件に達していなくても、それに紐づく農薬の記録が先に1000件を超える
ということでした。
例えば、1回の防除で3種類の農薬を混用した場合、作業記録としては「防除を1回した」という1件の記録ですが、使用した農薬は3件分記録されます。
つまり、
防除1回
├─ 農薬A
├─ 農薬B
└─ 農薬C
という形です。
そのため、作業記録そのものは863件しかなくても、それぞれの作業に紐づく農薬の記録を合計すると1000件を超えていました。
以前から「作業記録が増えたら、いずれ1000件上限への対策が必要」と考えていましたが、実際には作業記録より先に、そこに紐づく農薬データが上限に達したわけです。。
修正①:農薬・肥料データを分割して取得する
そこで、一度にすべて取得する方法をやめました。
ログIDを一定件数ずつに分割して取得します。
typescript
// 修正前:一括取得(1000件上限に引っかかる)
const { data: pesticidesData } = await supabase
.from("cult_log_pesticides")
.select("...")
.in("log_id", logIds);
// 修正後:100件ずつに分割して取得
const chunkSize = 100;
const allPesticidesData = [];
for (let i = 0; i < logIds.length; i += chunkSize) {
const chunk = logIds.slice(i, i + chunkSize);
const { data } = await supabase
.from("cult_log_pesticides")
.select("...")
.in("log_id", chunk);
if (data) allPesticidesData.push(...data);
}
という形に変更しました。
logIds を100件ずつに分けて取得し、最後に結果をまとめます。
肥料明細も同じような構造だったため、こちらも分割取得に変更しました。
これで農薬情報は再び表示されるようになりました。
ところが、これで1000件問題は終わりではありませんでした。
症状②:過去の栽培期間のデータが表示されない
その後、作業履歴で別の問題に気づきました。
過去の栽培期間を選択すると、
「作業記録がありません」
と表示されます。
ところがデータベースを見ると、過去の作業記録はちゃんと残っています。
さらに、データ量の少ないデモ環境では正常に表示されます。
ここで再び1000件上限を疑いました。
cult_logs の件数を確認すると、1000件を超えていました。
つまり今度は、関連する農薬明細ではなく、
作業ログ本体の cult_logs が1000件を超えた
ということです。
取得処理は新しいログから並べていたため、取得できた1000件より古いログが画面から見えなくなっていました。
データが消えたわけではありません。
取得できていなかっただけです。
最初の対策:.range(0, 9999)を付けた
このとき最初に行った対策が、
const { data: logsData } = await supabase
.from("cult_logs")
.select("...")
.eq("owner_id", ownerId)
.order("log_date", { ascending: false })
.order("created_at", { ascending: false })
.range(0, 9999);
という修正でした。
当時は、
「これで最大10000件まで取得できる」
と考えていました。
年間1200件程度なら、10000件に達するのはかなり先です。
これで当面大丈夫だろうと思っていました。
しかし、ここに勘違いがありました。
.range(0, 9999)を書いても10000件取れるとは限らない
その後さらにログが増え、cult_logs が1323件になった頃、また古い作業履歴が表示されない問題が発生しました。
「10000件まで取得するようにしたはずなのに、なぜ?」
改めて調べると、原因はSupabase側のMax Rowsでした。
.range(0, 9999)
と書くことと、
実際に10000件返してもらえることは別です。
Supabase側の1レスポンスの上限が1000件なら、9999まで要求しても1000件で止まります。
つまり、
.range(0, 9999)
↓
10000件取得
ではなく、私の環境では実質、
.range(0, 9999)
↓
Supabase側の上限
↓
1000件
となっていました。
一度1000件問題を直したつもりだったのに、実は根本的な解決になっていなかったわけです。
修正②:1000件ずつ最後まで取得する
そこで今度は、取得上限そのものを大きくする考え方をやめました。
1000件ずつ取得し、次の1000件、その次の1000件……と最後まで繰り返す方式に変更しました。
イメージとしてはこんな感じです。
const PAGE_SIZE = 1000;
const allLogs = [];
let from = 0;
for (;;) {
const { data, error } = await supabase
.from("cult_logs")
.select("...")
.eq("owner_id", ownerId)
.order("log_date", { ascending: false })
.order("created_at", { ascending: false })
.order("id", { ascending: false })
.range(from, from + PAGE_SIZE - 1);
if (error) throw error;
allLogs.push(...(data ?? []));
if (!data || data.length < PAGE_SIZE) {
break;
}
from += PAGE_SIZE;
}
1323件なら、
1回目:0〜999 → 1000件
2回目:1000〜1999 → 323件
という形です。
2回目が1000件未満なので、そこで終了します。
これならSupabase側のMax Rowsが1000のままでも、必要なデータを最後まで取得できます。
ページングでは並び順も重要だった
もう一つ注意したのが order() です。
複数回に分けて取得する場合、並び順が不安定だと、ページの境界でデータが重複したり欠けたりする可能性があります。
そのため、
.order("log_date", { ascending: false })
.order("created_at", { ascending: false })
.order("id", { ascending: false })
のように、最後は一意になる id まで含めて順序を固定しました。
単に、
.range(0, 9999)
を、
.range(0, 999)
.range(1000, 1999)
に分ければ終わり、というわけでもありませんでした。
他のページも改めて点検した
作業履歴で同じ問題を二度踏んだので、今度はアプリ内の他の取得処理も確認しました。
その結果、同じようにデータ量が増えると1000件上限に当たる可能性がある処理がいくつか見つかりました。
実運用で影響が大きそうなものから修正しています。
農薬使用一覧
農薬使用一覧では、防除ログを1000件ずつ最後まで取得するように変更しました。
農薬明細についても、ログIDを分割するだけでなく、返却される明細自体が1000件を超えても取得できるようにページングしています。
使用回数や散布日が一部だけ欠けると、農薬管理としては困るので、ここは優先して修正しました。
農薬・肥料・作業マスタの「使用済み判定」
これは表示だけの問題ではありません。
例えば農薬マスタを削除するとき、
1000件まで確認
↓
1001件目にその農薬の使用履歴がある
↓
履歴なしと誤判定
となれば、本当は使用済みのマスタを削除できてしまう可能性があります。
そこで一覧側の使用済み判定は全ページを取得するようにし、削除時には必要に応じて対象IDに絞って「1件でも履歴が存在するか」を確認する方式に変更しました。
履歴確認自体に失敗した場合も、「未使用」とは判断せず、削除を中止します。
収穫履歴
収穫データも使い続ければ増えていきます。
こちらも1000件ずつ取得して最後まで読む方式に変更しました。
これで1000件を超えても古い収穫記録が見えなくなることはありません。
作業登録画面の「直近作業」
作業登録画面には、そのハウスで最近何をしたのか確認するための「直近作業」を表示しています。
これも対象期間のログが1000件を超えると、種類によっては本当の最新記録が取得対象から落ちる可能性がありました。
現在の栽培期間・現在の準備期間に絞ったうえで、必要なログを最後まで取得してから直近作業を判定するように変更しました。
すべてを無制限対応したわけではない
今回、アプリ内のすべての select() を片っ端からページング化したわけではありません。
例えば、
- 1000件を超えるほど大量のマスタ
- 1回の取得で極端に大量の関連データが返るケース
- 1000畝を超えるような規模
など、現在の使い方では現実的ではない箇所もあります。
そういったものまで複雑な実装にするのではなく、実際に増えるデータ、欠けると困るデータから優先して対策しています。
今後必要になれば、その時点で追加対応します。
おまけ:農薬使用一覧も少し改善
1000件問題とは別ですが、農薬使用一覧を調べたついでに小さな改善も加えました。
ハウスごとの使用回数、
No.10:3回
のような表示をクリックすると、その農薬をそのハウスで使用した日付一覧を展開できるようにしました。
日付データを最初から全部読み込むのではなく、クリックされた時点で取得します。
必要なときだけ問い合わせることで、初期表示で余計なデータを読み込まないようにしています。
まとめ
今回の1000件問題は、結果的に三段階ありました。
① 農薬明細が1000件を超えた
↓
分割取得へ変更
② 作業ログ本体も1000件を超えた
↓
.range(0, 9999) を追加
③ それでも再発
↓
Max Rowsが1000なら
.range(0, 9999)でも1000件しか返らない
↓
1000件ずつ最後まで取得する方式へ変更
最初からページングしておけばよかった、と言えばその通りです。
ただ、個人で実際に使いながら作っているアプリなので、データが少ない開発段階ではなかなか表面化しませんでした。
特に厄介だったのは、
「データが登録されていない」のではなく、「DBには存在するのに取得できていない」
という点です。
画面だけを見ると、データが消えたように見えます。
そして今回もう一つ分かったのは、
.range(0, 9999)と書いたから10000件取得できる、とは限らない
ということでした。
Supabase側の取得上限が1000件なら、1回で10000件を要求するのではなく、
1000件 × 必要なページ数
として取得する必要があります。
実運用でデータが増えてきて、「DBにはあるのに画面に出てこない」という症状が出たら、取得件数の上限を一度確認してみるといいかもしれません。
