前回は、メモの受付と履歴の作成を別プロセスに分けました。そこで残ったのが、失敗したジョブをどう再試行するか、途中で止めても履歴が二重にならないか、という点です。シリーズの最後は、この二つを実装して確かめることにしました。
今回は図を整えるだけで終わらせず、ワーカーを実際に強制終了します。止めた位置と、再起動後にDBへ残る状態を照合するところまでを扱います。
今回はClaude Codeで実装する
今回の作業環境を整理しておきます。実装とテストの作成にはClaude Code 2.1.258を使いました。その後、Codexでコードの確認とテストの再実行を行っています。Archifyに渡す図のJSONの作成・調整と、画像化・記事の整形もCodex側です。Claude Codeがコードから図まで自動生成した、という記録ではありません。
Archifyは、今回もローカルにあるSkill版を使っています。構造化したJSONから図を生成する部分と、その図の意味が実装に合うかを確認する部分は分けて考えます。
実装は前回のコードを残し、examples/archify-notes-api-recoveryに作りました。Pythonの標準ライブラリとSQLiteだけで動く、ローカル専用のAPIです。外部へのメール送信やAWSサービスへの接続はありません。
Claude Codeには、同じリクエストの再送、回数制限付きの再試行、コミット前後の強制終了を検証対象として渡しました。テスト用のSQLモックではなく、実際のHTTP、SQLite、別プロセスのワーカーを動かす条件も付けています。
まず、同じPOSTを送り直せるようにする
再試行には二つの場所があります。APIへのリクエストを送り直すことと、受付済みのジョブをワーカーが処理し直すことです。ここを一緒にすると、どの重複を防いだのか曖昧になると感じました。
API側にはIdempotency-Keyを追加しました。同じキーと同じ本文なら、新しいメモを作らず、前に作ったメモとジョブのIDを返します。今回の本文比較は、前後の空白を除いたテキストのSHA-256で行います。
新しいDBファイルを指定してAPIを起動します。前回のDBはスキーマが違うため、今回の実装では自動移行せずに拒否します。
cd examples/archify-notes-api-recovery
python3 -m notes_api.server --db ./finale.sqlite3 --port 8765
別のターミナルから、同じコマンドを二回送ります。
curl -sS http://127.0.0.1:8765/notes \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: finale-demo-1' \
-d '{"text":"再送しても増やさないメモ"}'
初回は202でreplayed: false、二回目は同じIDを返してreplayed: trueになります。実行検証では、別の同一キーに12件の同時リクエストを送り、すべて同じメモとジョブを指すことも確認しました。
同じキーで本文だけを変えた場合は409です。別のキーなら同じ本文でも別のメモを作ります。本文が似ているものを探して消す仕組みではありません。
履歴を戻しても、失敗した回数は残したい
ワーカーにはattempts、max_attempts、next_run_at、last_errorを持たせました。DBに保存する状態は、pending、retry_wait、completed、failedの四つです。
ここで気になったのは、エラー時にすべてをロールバックすると、失敗回数まで戻ってしまうことでした。それでは何度失敗しても上限に到達しません。そこで、履歴の作成と完了状態への更新をSAVEPOINTで囲んでいます。

画像をクリックすると元のサイズで開きます。
処理順は、BEGIN IMMEDIATE、ジョブ選択、試行回数の加算、SAVEPOINT、履歴のINSERTです。成功すればジョブをcompletedにして、履歴と一緒にコミットします。
履歴のINSERTなどでSQLiteのエラーを捕捉した場合は、SAVEPOINTまで戻します。その後、再試行の余地があればretry_wait、上限ならfailedを記録してコミットします。既定の上限は合計3回です。
内側のRELEASEだけではDBへの確定は終わりません。最終的な確定は外側のコミットです。この区別はSQLiteのSAVEPOINTの説明とも照合しました。
ワーカーは短い処理を同じSQLiteトランザクション内で終える設計です。ジョブの選択から確定まで書き込みロックを持つため、DBにrunning状態やリース期限は置いていません。外部APIを長時間呼び出す場合まで、この設計をそのまま使えるとは考えていません。
Archifyでは再試行の戻り道を追加する
前回はAPIとワーカーの分担が中心でした。今回は状態の変化を追いたいため、Archifyのlifecycle形式で図にしています。

画像をクリックすると元のサイズで開きます。
見たかったのは、retry_waitから処理へ戻る矢印と、上限に達したときのfailedへの分岐です。期限前は処理せず、期限を過ぎた後のポーリングで候補に戻ることを、コードの選択条件と照合しました。
最初の図は生成の検査に通ったものの、ブラウザーで表示すると小さい画面では縦にはみ出しました。重複した説明を本文へ移し、配置を調整しています。最終版は9項目の成果物検査と、1440×900から2048×1320までの4サイズの表示検査を通しました。ライト・ダークの画像も確認しています。
ただし、図が検証に通ることと、再試行が正しく動くことは別です。図の検査がSQLの原子性を証明するわけではありません。そこは次の実行結果で確かめます。
障害を残す場合と、取り除く場合を分けて動かす
障害は、履歴のINSERTを拒否するSQLiteのトリガーで入れました。実際にIntegrityErrorが発生する状態を作っています。
一回失敗させた後に障害を取り除くと、次のワーカーは期限まで待ち、二回目の試行でcompletedになりました。履歴は1行です。障害の解除は検証側で行っています。自動なのは期限の判定と再試行であり、原因を自動修復したわけではありません。
障害を残したケースは、retry_wait、retry_wait、failedの順に進みました。attemptsは3、履歴は0行です。失敗したジョブを無制限に拾い続けないことを確認できました。
python3 -m notes_api.worker --db ./finale.sqlite3 \
--max-polls 5 --poll-interval 1 --retry-delay 1
このCLIは上限付きの検証用です。max-pollsは確認回数で、試行回数ではありません。また、最初の完了や打ち切りで終了するため、キュー全体を処理し続ける常駐ワーカーではありません。
終了コードが0でも、対象がなくidleだった場合や、再試行待ちで終わった場合があります。完了の判断には、出力された状態やGET /jobs/{id}を見ます。idleも、将来の再試行待ちが残っていないという意味ではありません。
コミットの直前と直後で、強制終了してみる
次はSQLエラーではなく、プロセス自体を止めました。テスト用フックから合図のファイルを書かせ、その合図を確認して対象のワーカーだけにSIGKILLを送ります。適当な秒数を待って、止めた位置を推測する方法にはしていません。
一つ目は、履歴をINSERTした後、コミットする前です。停止後にDBを開くと、ジョブはpending、試行回数は0、履歴は0行でした。新しいワーカーを起動すると、一回目の試行として完了しました。
二つ目は、コミットが返った後、完了をログへ出す前です。今度は停止してもcompletedと履歴1行が残りました。再実行したワーカーはidleになり、履歴は増えませんでした。

画像をクリックすると元のサイズで開きます。
ワーカーを二重起動するケースでも、片方が完了し、もう片方はidleでした。履歴は1行です。完了済みを候補から除く条件に加えて、note_events.job_idにはUNIQUE制約を置いています。
ここで、attemptsはクラッシュ回数ではないことも分かります。コミット前に強制終了すると加算自体が戻るためです。強制終了を繰り返すジョブまで3回で止めたいなら、別の記録方法が必要です。
テストが通った後に、環境の見落としが見つかった
Claude Codeが作成した40件のテストを、Codex側でも再実行しました。加えて、9シナリオ・77項目の実行検証を行っています。旧DBの拒否や、ワーカーのDBパスを間違えたときに空のDBを作らないことも対象です。
python3 -m unittest discover -s tests -t . -v
python3 scripts/verify_runtime.py
ここで一つ見落としがありました。最初の実行はPython 3.13.5に組み込まれたSQLite 3.51.2でしたが、公式資料を読み直すと、WALモードの複数接続に関係する既知の不具合の修正前でした。修正は3.51.3以降などに入っています。SQLiteのWAL-reset bugの説明
そこで、既存の別環境にあるPython 3.12.14/SQLite 3.53.1でも同じコードを動かしました。40件のテストと77項目は、この環境でもすべて成功しました。図3は後者の実行記録を使っています。成功したテストだけを見て、利用中のDBの修正状況まで確認したつもりになっていた点は反省です。
evidence/runtime.jsonには、実行環境、プロセスID、DBをSELECTした結果、ソースのSHA-256を残しました。自分の環境で使われるSQLiteは、python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'で確認できます。
この検証で確認していないことも残す
一方で、今回の成功はローカルの小さな検証での結果です。電源断やディスク障害、外側のCOMMIT自体が失敗する経路は試験していません。COMMIT時のエラーは、保存済みと断定せず
metadata_persisted: unknownを返す実装にしていますが、この分岐はコードの確認までです。今回の履歴は、ジョブと同じDBに書いています。メール送信や決済のような外部への副作用まで、一回だけ実行できると確認したわけではありません。Outboxで重複処理への対策が必要になる点は、AWSのTransactional outboxの説明も参考にしました。
認証、キーの利用者ごとの分離、キーの保存期限、指数バックオフ、failedからの再投入画面は作っていません。エラーの詳細もローカル検証向けです。本番へそのまま持ち込むための実装にはしていません。
図を作るところから始めて、ここで一区切りにする
シリーズの初めは、Archifyで小さな図を一つ作るところから始めました。APIを実装し、保存する処理を増やし、ワーカーを分けると、気になる点が箱の配置からコミットの位置へ変わっていきました。
私には、図の矢印を見ながら、この経路を通すテストがあるかと戻れることが役に立ちました。ただ、図がきれいでも、実行結果や環境の問題は別に残ります。今回のSQLiteの確認漏れも、その一つでした。
最後に残せたのは、状態遷移の図と、その状態を実際に通した記録です。コードを変えたときは、矢印だけでなく、SQLの境目とテスト結果も一緒に見直す。自分の中では、そこまでを一組にするのがよさそうだと感じました。
なお、対象は自分で小さく作ったリポジトリです。未知の大規模なコードをArchifyがどの程度正しく読み取れるかは、このシリーズでは評価していません。その範囲は広げず、今回で一区切りにします。
ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。
- 選考ではありません
- 履歴書不要
- 技術の話が中心
- 所要時間30分程度
- オンラインOK