本番/searchが壊れた本当の原因はlocalStorageの上限超過だった

開発mdx-playground

本番/searchが壊れた本当の原因はlocalStorageの上限超過だった

前日仕込んだ修正の後始末を頼んだだけのつもりだった。 非公開記事のタイトル言及を誤検知していた検証ゲートを直してもらい、あとはビルドとデプロイを済ませて計画書を閉じるだけ。 そのはずが、積み残しを片付けてもらう途中で、本番の検索が壊れていることが出てきた。

デプロイして、確かめる

学習ゲートは今回はスキップさせ、ビルドとデプロイをまとめて進めてもらった。 本番のbuildIdが今回のビルドと一致するかを照合させると、3回続けて食い違った。 原因はCDNの伝播待ちで、少し置いて取り直すと今回のビルドと一致した。

本番のsql_dump.txtは11,335,092バイト。 ローカルのdistと完全に同じサイズで、前日の12,546,940バイトより1.2MB軽くなっていた。 復号して検査させると、ヒットは1件だけ。 本文もタイトルも0件でslugのみのヒットだったので、中身を確かめさせた。 実体は別の公開記事のチェックリストに書かれたファイル名で、非公開記事の漏えいではなかった。 元の問題は本番で解消していた。

Step 6は、もうやらないと決めていた

未了事項の後始末と計画書の締め、過去デプロイの始末、本番ダンプの全置換までまとめてやっておいてほしいと頼んだ。

計画書のStep 6を確認させると、「過去デプロイの始末は、決裁③がLATERのため着手しない」とすでに自分で書いていた。 まだ手を付けていないと思っていた項目が、実は先送りすると決めていた項目だったわけだ。 本当に残っていたのは、Step 5(復号したダンプが実際にDBとして組み立てられるかの確認)と、4件の受け入れ条件のほうだった。

Step 5は1871文すべてが成功し、失敗はゼロ、integrity_checkもok、1779行という結果になった。 ビルドログの「1779 parsed」と数字が一致している。 受け入れ条件も、フラグ付き169件とディレクトリ29件を合わせた198件の非公開パスが、DBに1件も混入していないことを確認できた。

途中、カレンダーの8/13のセルだけ記事が0件に見えて身構えたが、記事側と日記側で振り分けのロジックが違うだけで、記事そのものは本番にちゃんと存在していた。

検索だけが壊れていた

一覧、記事ページ、関連記事、目次、画像。 ここまでは順調に確認が進んだ。 ところが検索だけno such column: "unpublished"というエラーを出して結果を返さなかった。

ダンプのテーブルには、その列が22列の中にちゃんとある。 サーバー側の配信物がおかしいわけではなさそうだった。 キャッシュを無視して再読み込みしても同じエラーが出る一方、storageを分離した新しいタブで同じURLを開くと、エラーもなく21件ヒットした。 今日の記事も検索結果の先頭に出てくる。 本番は正常で、壊れているのは手元のブラウザに残っていたキャッシュのほうだった。

正体は、Nuxt ContentがlocalStorageに置いているクライアントキャッシュだった。 壊れた端末には4.7MBのgzipキャッシュが残っており、そのときのchecksumは本番の現在値と完全に一致していた。 本番のダンプは11.3MB、localStorageの上限はおよそ5MB。 checksumは保存できるが、11.3MBの本体は上限を超えて保存に失敗する。 その結果、キャッシュは有効だと毎回誤判定されてしまう。 今回のデプロイでダンプはむしろ12.5MBから11.3MBへ軽くなっていたので、デプロイが原因ではなく、前からあった問題を今日たまたま踏んだだけだった。 issueを1本切って残した。

計画を締めようとしたら、ブランチが変わっていた

未了事項を片付け終え、計画書を閉じようとした矢先、作業ツリーの状態がおかしいことに気づいた。 ブランチがmasterに切り替わっており、PR #72がすでにマージされている。 手元で作った2つのコミットがmasterに入っているか確かめると、入っていなかった。

もう1台のWindowsマシンから先にマージが上がっていたらしい。 2つのコミットをcherry-pickでmasterに取り込み、計画書を締めてコミットした。 プッシュもしてほしいと頼むと、拒否された。 ローカルがoriginより2つ遅れている。 調べると、遅れているだけでこちらが進んでいるコミットはゼロ、つまり3つのコミットはすでにoriginに入っていた。 別のセッションが記事のマージと一緒に持ち上げたとみられる。 プッシュは、そもそも必要なかった。

新しいセッションで検索そのものに向き合う

翌セッションを開き、本番の/searchが壊れているので直してほしい、とあらためて頼んだ。 ブラウザのキャッシュが原因だとはもう分かっていたが、誰もがstorageを分離したタブを持っているわけではない。 放置してよい問題ではなかった。

origin側の未取得コミットとローカルの未追跡ファイルが競合しないかをまず確認させ、それから調査に入ってもらった。 ダンプの列ごとの容量を集計させると、body(本文AST)だけで全体の96.3%を占めていた。 /searchはプリレンダー対象から外され、完全にクライアント側で描画される設定になっている。 本番でどのページがクライアント側のSQLiteを読みにいくかを実測させた。

途中、Chrome DevTools MCPが応答しなくなった。 ルールどおり先にissueを残させ、待たずにdistの静的な生成結果からプリレンダー状況を洗い出す作業を続けさせた。 ちょうどそのころ、measure-deploy.ps1nuxt generateが動いているのに気づいた。 本番デプロイがまさに進行中だった。 今回のビルドは修正前のコードのままなので、この回では/searchは直らない。 作業は止めずに計画書づくりを続けさせた。

Codexに突かれるたびに方針が変わっていった

計画の初稿ができ、Codexにレビューさせようとしたら、mise ERROR cannot find binary pathで起動が失敗した。 exit 0なのに実際には動いていない、という分かりにくい壊れ方だった。 mise経由のCodexが壊れていることは前から分かっていたので、公式アプリのバイナリを直接呼ぶ形に切り替えて動かし直した。

1回目のレビューで、致命的な穴が2点返ってきた。 一度きりのキャッシュ掃除では再発時に救えないことと、window.localStorageへの参照自体が例外を投げうることだった。 計画を直して再レビューに出すと、今度はresumeがスレッド競合で失敗し、新規スレッドで回すことになった。 計画のタイトルはGit Bashのパス変換に巻き込まれて/search...C:/Program Files/Git/search...に化けており、これも直させた。 目次と決裁フォームの描画は自分の目で確認し、日本語の強調が1箇所壊れているのも直させた。 この再レビューで、サイズだけで判定すると容量が小さい旧世代のダンプを取りこぼす、という3点目の指摘も見つかった。

このやり取りの中で、localStorageの壊れ方の理解も一部訂正することになった。 書き込みは成功か例外かのどちらかで、書きかけの中途半端な状態は残らない。 実際に起きていたのは、checksumだけが毎回最新に更新され、11.3MBの本体は容量超過で書き込みに失敗し、以前保存できていた古い世代のダンプがそのまま残るというねじれだった。 その古い世代にはunpublished列がまだない。

そこで、検索機能そのものを廃止する案も選択肢に入れるよう付け加えた。 自分のログを検索する人はほとんどいないし、インデックスされていないものは検索される必要もない。 調べものはClaude Codeやcodexに頼めば足りる。 この理屈のほうが筋がよかった。 計画の軸を「直す」から「消す」へ切り替えさせると、消す対象は驚くほど狭かった。 実際に/searchへ張られていたリンクは、トップページの導線1箇所だけだった。

書き直した計画をもう一度Codexにレビューさせると、今度は前提そのものを覆す指摘が来た。 「/searchを消しても、そのアクセスはcatch-allの404に落ちて同じ8.3MBを取得する」というものだ。 本番で存在しないURLを実際に開かせて確かめると、指摘のとおりだった。 しかも問題は/search固有ではなく、404に来た訪問者は誰でも同じダンプを取りにいっていた。 想定より広い範囲を塞ぐ必要があるとわかり、catch-all側の根治(Step 3.5)を計画に加え、決裁項目も作り直した。

4つの決裁と、手を止めたままの実装

計画書をChromeに表示し、決裁フォームの描画を自分の目で確かめてから提出を待った。 返ってきた回答は、404経路もいっしょに直す、/search/blog/へ301で送る、壊れたlocalStorageを掃除する、11.3MBのダンプを今回いっしょにデプロイ物から外す。 4件とも承認だった。

実装に入る前に、ダンプを外すかどうかだけもう一度確認したいと問い直した。 非公開記事の検査スクリプトがまさにこのダンプを検査対象にしていること、2026-08-12に非公開記事が漏れた経路が同じダンプだったことを踏まえ、外す方向を維持するよう勧められた。

git pullはまだ実行していない。 退避コピーを作っただけで、ソースコードは1行も触っていない。 計画書に決裁結果と、外すかどうかの再検討を書き足し、実装は次のセッションに持ち越すことにした。

残ったもの

昨日の漏えい対策は本番で確かに直っていた。 壊れていたのは、まったく別の場所だった。

localStorageの上限は、5MBという小さな数字がSQLダンプという大きな配信物とぶつかっただけの話だ。 それでいて、checksumだけが更新され本体の保存は失敗する、というねじれがデプロイのたびに作り直されていた。 壊れたまま自力では直らない、という壊れ方の悪さが厄介だった。 直すより消すほうが早いと判断できたのは、検索という機能そのものが、このサイトではほとんど誰にも使われていなかったからだ。 使われない機能を直すために、404という別の入口まで塞ぎにいく実装を背負う必要はなかった。