開発book-knowledge-base

裁断スキャンした投資の参考書(214ページ)を /yomitoku でOCRしてMarkdownに起こし、Turso(libSQL/SQLite互換のクラウドDB)へ取り込んだ。ページ単位の61チャンクをセクション単位の47チャンクに統合するスクリプトを走らせたら、出力を一行も吐かないまま固まった。240秒待っても画面は無言。原因はEmbedded ReplicaのWALロックだった。ハングしたプロセスをkillし、HTTP直接接続版に書き換えたら一発で通った。しかも調べ直したら、統合自体は最初のスクリプトで既に終わっていた。

やったこと(時系列)

ある著名投資家の財務分析書を裁断してスキャンした、約39MB・214ページのPDFがある。これを蔵書ナレッジベースに取り込むのが今日の作業だった。

  • /yomitoku コマンドで214ページ全てをMarkdown化。本文中の図を60ファイル抽出し、連番でリネームした
  • OCRしたテキストをTursoに61チャンクとして格納。既存の amazon_metadata テーブルと紐付けて、書誌情報とコンテンツが1冊ぶん揃った
  • 続けて /restructure-book でページ単位のチャンクをセクション単位に統合(61→47チャンク)。OCRが拾った見出しはノイズだらけで、"35" や "38" は章番号ではなく装飾記号の誤読だった。章名を一つずつ正規化した

ここまでは順調だった。詰まったのはセクション統合のスクリプトを実行した瞬間だ。

出力ゼロのままハングした

統合スクリプトを走らせると、ターミナルが沈黙したまま動かなくなった。sleep で待とうとしたらブロックされたので、バックグラウンドの待機ループで監視に切り替えた。それでも240秒、出力は一行も出てこない。プログレスバーもエラーもない。ただ無言で固まっている。

「なかなか遅いですね。なんでだろう」と首をひねった。OCRもDB格納もここまでサクサク終わっていたのに、最後のセクション統合だけが完全に止まっている。これは遅いのではなく、何かを待ち続けている挙動だ。

原因はEmbedded ReplicaのWALロックだった

心当たりがあった。CLAUDE.mdに既知問題として書いてあるやつだ。Embedded ReplicaのWALロックである。

Embedded Replicaはローカルにレプリカファイルを持ち、そこへ書いてからクラウドへ同期する方式だ。先行プロセスが開いた接続のWALロックがレプリカファイルに残っていると、後から起動したプロセスの BEGIN がロック取得待ちで固まる。タイムアウトもエラーも出ず、ただ待ち続ける。240秒の沈黙はこれだった。

ハングしていたのはuvが起動したPythonプロセス群で、親子合わせて9個ぶら下がっていた。これを全部killした。Claude Codeのnodeプロセスには指一本触れないよう、Pythonのプロセスツリーだけを狙って落とした。

HTTP直接接続版に書き換えて救出した

ロックの巣窟であるレプリカファイルを使うのをやめて、Tursoクラウドへ直接書くHTTP直接接続版にスクリプトを書き換えてもらった。レプリカファイルを経由しないので、WALロックの待ち合わせが発生しない。

# Embedded Replica(ローカルレプリカ経由 → WALロックでハング)
conn = libsql.connect("local.db", sync_url=TURSO_URL, auth_token=TOKEN)

# HTTP直接接続(クラウドへ直接 → ロック待ちなし)
conn = libsql.connect(database=TURSO_URL, auth_token=TOKEN)

書き換えた版を走らせる前に、現状を確認しておこうとHTTP接続でチャンク数を数えた。ここで予想外の事実にぶつかった。

kill した時点で、統合は既に終わっていた

数えたら、チャンクは既に47になっていた。統合は完全に成功していたのだ。

最初のEmbedded Replica版スクリプトは、ハングして何もできていなかったわけではなかった。UPDATE/DELETEを実行し、トランザクションをcommitまで完了させていた。固まっていたのはその後、最後に呼ぶ sync()(レプリカの変更をクラウドへ同期する処理)だった。commitした時点でクラウド側のデータはもう書き換わっていて、killしても確定済みの変更は消えなかった。

つまり「ハングして失敗した」と思い込んでいたが、実際には「処理は終わっていて、後始末の sync() だけが固まっていた」が正解だった。FTSのリビルドと検索テストもHTTP接続で流して、47チャンクが正しく引けることを確認した。

再発防止:sync() を全部抜いた

同じ罠を踏まないよう、restructure-book のスラッシュコマンド定義テンプレート(.claude/commands/restructure-book.md)のStep 4を直した。

  • DB書き込みパートをHTTP直接接続に統一
  • ハングの元凶だった sync() の呼び出しを、テンプレート内の7箇所すべて削除

これで次に /restructure-book を実行したときは、最初からレプリカを経由せずクラウドへ直接書く。sync() 待ちで固まる経路自体が消えた。

WALファイルが18.9MBに肥大化していた

ハングの巻き添えで、ローカルのWALファイルが18.9MBまで膨らんでいた。CLAUDE.mdの手順どおり、レプリカファイルを退避した。データの本体はクラウドにあるので、ローカルのレプリカを退かすのは非破壊的な操作だ。次に接続したとき、レプリカは自動で再構築される。

レプリカ再接続で動作確認した

「レプリカに接続してちゃんと動いているか見て」と念押しされた。退避したあとEmbedded Replicaで繋ぎ直すと、レプリカがゼロから組み上がり、9.2秒で全テストが完走した。蔵書58冊ぶんがフル再構築され、検索も問題なく返ってくる。HTTP直接接続で逃げただけで終わらせず、本来のEmbedded Replica経路でも健全に動くところまで見届けられた。

学びメモ

  • ハング=失敗とは限らない。 commitが通っていれば、その後の sync() で固まってkillしても、確定済みの変更はクラウドに残る。まず「どこまで終わっているか」をHTTP接続で数えてから、やり直すか判断する
  • Embedded Replicaの便利さには、WALロックという裏面がある。 先行接続のロックが残ると、後続の BEGIN が無言で待ち続ける。書き込みバッチを連続で回す処理は、HTTP直接接続のほうが事故らない
  • プロセスをkillするときは木を見て狙う。 uvが起こすPythonの親子9プロセスだけを落とし、Claude Codeのnodeには触れない。名前で一括killすると自分の足を撃つ
  • 既知問題はCLAUDE.mdに書いておくと、240秒固まった瞬間に「あれだ」と当たりがつく。今日それに救われた