Claude Media
Claude Codeでアウトボックスパターンを実装しクラッシュ試験で確かめる

Claude Codeでアウトボックスパターンを実装しクラッシュ試験で確かめる

DB更新とメッセージ送信の不整合を、アウトボックスで防ぐ実装をClaude Codeに書かせ、kill -9を挟んだ件数照合で確かめる手順です。

Claude Codeでアウトボックスパターンを実装しクラッシュ試験で確かめる

アウトボックスパターンは、DBの更新とメッセージ送信を「同じトランザクションに書いた行」でつなぐ方法です。実装はClaude Codeに任せられますが、正しいかどうかは読むだけでは決まりません。プロセスを途中で kill -9 し、注文数・送信数・受信側の処理数を数え直す試験が要ります。この記事では、その試験を先に用意し、Claude Codeに「通るまで直させる」流れを、手元で動かした数字つきで示します。

アウトボックスが防ぐ不整合

注文を保存して、注文イベントをブローカーに送る処理を考えます。2つの操作を原子的に行えれば安全ですが、2フェーズコミット(2PC)は使えないことが多くあります。DBかブローカーのどちらかが2PCに対応していない場合があり、対応していても両方に結合したくないからです。

2PCなしでは、順序のどちらを選んでも穴があります。

  • トランザクションの途中で送ると、その後にコミットされる保証がない
  • コミット後に送ると、送る前にクラッシュしても気づけない

アウトボックスは、送るメッセージを業務データと同じトランザクションでDBの表に書き、別のプロセスがそれをブローカーへ送る形にします。登場人物は4つです。

役中身
Sender中身メッセージを送りたいサービス
Database中身業務データとアウトボックスを持つDB
Message outbox中身送信待ちメッセージを入れる表(NoSQLなら各レコードのプロパティ)
Message relay中身表の行をブローカーへ送るプロセス

この構成で、DBのコミットが成功したときに限りメッセージが送られ、送信順もアプリケーションが書いた順になります。一方で、リレーは同じメッセージを2回以上送ることがあります。ブローカーに送った直後、「送った」と記録する前に落ちた場合は、再起動後にもう一度送るからです。そのため受信側は冪等でなければならず、処理済みのメッセージIDを記録する方法が挙げられています。

コミット後に送るだけの実装は、実際に取りこぼす

先に、守りのない実装を壊してみます。注文をINSERTしてコミットし、その直後にログファイル(ブローカーの代役)へ1行書くだけのスクリプトです。コミットと送信の間に1ミリ秒だけ間を置き、1秒強走らせて kill -9 します。

python3 naive.py & P=$!; sleep 1.5; kill -9 $P
sqlite3 naive.db "select count(*) from orders"; wc -l < naive_broker.log

手元のPython 3.12.2とSQLite 3.45.2で4回走らせた結果は次のとおりです。

回orders(DB)送信ログの行数差
1orders(DB)753送信ログの行数752差1件の取りこぼし
2orders(DB)523送信ログの行数522差1件の取りこぼし
3orders(DB)594送信ログの行数594差0
4orders(DB)656送信ログの行数655差1件の取りこぼし

4回中3回、DBには注文があるのにイベントが存在しない状態になりました。取りこぼしは1件ずつですが、本番では「注文は入ったのに出荷指示が来ない」という形で現れ、後から原因を追いにくい不具合です。killのタイミングは毎回ずれるため、0件の回があることも含めて再現性の低い不具合だと分かります。

実装を頼む前に、確かめ方を決める

Claude Codeのベストプラクティスには、完了を判定できる確認手段を渡すと、自分で結果を読んで直すループが回ると書かれています。確認手段はテストファイル、ビルドの終了コード、出力を期待値と突き合わせるスクリプトなどで、合否が読み取れるものならよいとされています。出力そのもの(実行したコマンドとその結果)を証拠として出させる点も勧められています。

アウトボックスは、まさにこの型に向いた題材です。実装を読んで「正しそう」と感じても、クラッシュ時の挙動は走らせないと分かりません。そこで、依頼文に次の3点を入れます。

注文保存とメッセージ送信の不整合をなくすため、アウトボックスパターンで実装して。
- 注文INSERTと outbox 行のINSERTは同一トランザクションにする
- リレーは未送信行を送ってから sent_at を更新する(at-least-once)
- 受信側は処理済み msg_id を主キーで記録して重複を無視する
完了条件: crash.sh を実行し、リレーを kill -9 で繰り返し落としたあとも
orders / shipments が一致し、未送信が0になること。
実行したコマンドとその出力を最後に貼って。

「at-least-once」と「受信側の冪等」を依頼文に書くのは、アウトボックスの欠点として、リレーの重複送信がパターン解説(microservices.io)に明記されているためです。ここを指定しないと、重複をなくそうとして送信後の更新を先に行う実装(送信前に落ちると取りこぼす)が出てくる可能性があります。この指定は、パターンが成り立つ前提を依頼文に写したものです。

実装の骨子

手元で動かした最小構成を示します。SQLiteを使い、ブローカーは追記専用のログファイルで代用しています。表は4つです。

c.executescript("""
CREATE TABLE IF NOT EXISTS orders(id INTEGER PRIMARY KEY, item TEXT);
CREATE TABLE IF NOT EXISTS outbox(id INTEGER PRIMARY KEY AUTOINCREMENT,
    payload TEXT NOT NULL, sent_at REAL);
CREATE TABLE IF NOT EXISTS processed(msg_id INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS shipments(order_id INTEGER PRIMARY KEY);
""")

送信側は、注文とアウトボックス行を1つのトランザクションに入れます。

c.execute("BEGIN")
cur = c.execute("INSERT INTO orders(item) VALUES (?)", (f"item-{i}",))
c.execute("INSERT INTO outbox(payload) VALUES (?)",
          (json.dumps({"order_id": cur.lastrowid}),))
c.execute("COMMIT")

リレーは未送信行を古い順に読み、ブローカーへ書いて fsync し、そのあとで sent_at を埋めます。落ちる位置が「書いた後、sent_at を埋める前」だと、ここが重複の発生源になります。

rows = c.execute("SELECT id, payload FROM outbox "
                 "WHERE sent_at IS NULL ORDER BY id LIMIT 20").fetchall()
for mid, payload in rows:
    f.write(json.dumps({"msg_id": mid, "body": json.loads(payload)}) + "\n")
    f.flush(); os.fsync(f.fileno())
    c.execute("UPDATE outbox SET sent_at=? WHERE id=?", (time.time(), mid))

受信側は、処理済み表への挿入と業務更新を同じトランザクションにし、主キー違反なら何もしません。

c.execute("BEGIN")
try:
    c.execute("INSERT INTO processed(msg_id) VALUES (?)", (m["msg_id"],))
    c.execute("INSERT INTO shipments(order_id) VALUES (?)", (m["body"]["order_id"],))
    c.execute("COMMIT")
except sqlite3.IntegrityError:
    c.execute("ROLLBACK")

ここでも同じ原則が使われています。リレーの「送信と記録」は分けられませんが、受信側の「処理済みの記録と業務更新」は同一トランザクションにできるため、重複を無害にできます。

kill -9を挟んだ結果

試験は crash.sh にまとめます。500件の注文を作り、リレーを起動して0.3〜0.7秒でkillする操作を最大5回繰り返し、最後にリレーを最後まで走らせて受信側を動かします。killのたびに件数を表示させます。4回目のkillの時点で未送信が0になったため、出力は4回分です。

bash crash.sh

手元の実行結果は次のとおりです(unsent は sent_at が空の行数)。

orders=500 outbox=500 unsent=500 broker_lines=0 shipments=0
kill #1: orders=500 outbox=500 unsent=379 broker_lines=122 shipments=0
kill #2: orders=500 outbox=500 unsent=225 broker_lines=277 shipments=0
kill #3: orders=500 outbox=500 unsent=102 broker_lines=401 shipments=0
kill #4: orders=500 outbox=500 unsent=0   broker_lines=503 shipments=0
final relay:   orders=500 outbox=500 unsent=0 broker_lines=503 shipments=0
after consume: orders=500 outbox=500 unsent=0 broker_lines=503 shipments=500
distinct msg_id in broker: 500

読みどころは3つあります。

  • orders と outbox は常に500で一致する。送信側のトランザクションは、killされてもずれない
  • kill #1で unsent は379、送信済みは121のはずが broker_lines は122。1行多いのは、書いたあと sent_at を埋める前に落ちた分
  • 最終的な broker_lines は503で、メッセージIDの種類は500。3件の重複が受信側で無視され、shipments は注文数と同じ500に収まる

重複が出ること自体が、at-least-onceの正常な姿です。重複が0件で終わった回があっても、安全だったとは言えません。「重複が出る回でも出荷が500で止まる」ことを確かめるのが試験の目的です。

試験で見る不変条件

件数の照合は、次の不変条件に整理できます。Claude Codeに渡す完了条件も、この形で書くと迷いが減ります。

不変条件破れたときの意味
orders = outbox破れたときの意味注文とメッセージのどちらかが片方だけコミットされた
最終unsent = 0破れたときの意味リレーが再起動後に再送できていない
broker_lines >= orders破れたときの意味メッセージの取りこぼし
shipments = orders破れたときの意味受信側の冪等が効いていない(多い)か、未処理がある(少ない)

先ほどの素朴な実装は、この表の3行目に当たる不変条件を破っていました。アウトボックス版は4つとも保たれています。なお、この試験はSQLiteと単一リレーでの結果です。リレーを複数台並べる場合は、同じ行を2台が拾う、送信順が崩れる、といった別の問題が出ます。パターン解説も、順序の保持は複数インスタンスをまたいで成立させる必要がある要件として挙げています。

試験をStop hookで完了条件にする

「通るまで直して」と頼んでも、Claudeは作業が終わったように見えた時点で止まることがあります。ベストプラクティスは、Stopフックを使うと、スクリプトが終了コード2で終わる間はClaude Code側が終了を止め、標準エラーに出した内容が理由として返ると説明しています。公式の例は型チェックですが、同じ形で試験に差し替えられます。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "bash verify.sh 1>&2 || exit 2" }
        ]
      }
    ]
  }
}

verify.sh は crash.sh を走らせ、上の不変条件のどれかが破れたら非ゼロで終わる小さなスクリプトにします。ブロックが8回続くと次のブロックは上書きされて終了するため、上限なしで回り続けることはありません。上限は環境変数 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられます。Stopフックが再発火する症状はClaude Codeの/goal Stop hookが止まらないで、フック自体が失敗したときに操作を止める設定はhooksのonFailureで失敗時に操作を止める設定にまとめています。

試験が重いときは、毎回のStopで走らせず、依頼文の完了条件に入れるだけでも運用できます。killを挟む今回の試験は、500件なら数秒で終わりました。

リレーの作り方と、書き忘れという欠点

今回のリレーは、未送信行を定期的に読みにいく方式です。パターン解説では、リレーの実装として2通りが挙げられています。

  • ポーリング型(Polling publisher): アウトボックスの表を定期的に読み、未送信行を送る。今回の骨子はこちら
  • トランザクションログの追跡(Transaction log tailing): DBの更新ログを読み、アウトボックスへの追加を検知して送る

前者は追加の仕組みが少なく、試験もしやすい方式です。後者はDBごとの対応が要るため、依頼文でどちらを使うかを指定しておくと、Claude Codeが別の仕組みを持ち込む余地を減らせます。また、イベントソーシングはアウトボックスの代替案として挙げられています。

もう1つ、パターン解説が欠点として挙げているのが、開発者がDB更新のあとにメッセージを書き忘れやすい点です。Claude Codeに機能を足させたとき、新しい書き込み経路だけアウトボックスを通らない実装になる可能性があります。対策は、書き込み経路ごとに「orders = outbox」の確認を試験へ足すことです。新しい経路を追加する依頼のたびに、同じ完了条件を付けてください。

つまずきやすい点

  • killのタイミングが毎回違う: 1回で通っても安心できません。5回以上繰り返し、重複が1件以上出た回を含めて確認します
  • sent_at の更新を送信前に移す: 重複は消えますが、送信前に落ちると取りこぼします。at-least-onceを保つ向きは「送ってから記録」です
  • 受信側の冪等を省く: 試験の shipments が注文数を超えて初めて気づきます。ブローカーは重複配信することがあるので、パターンの有無に関わらず冪等は必要です
  • 冪等性の検証そのものを別の題材で練習したい: 同じ「2回実行しても同じ結果」の確認は、インフラ側ではAnsibleのplaybookを冪等性の検証まで書く手順でも扱っています

まとめ

アウトボックスは、2PCなしで「コミットされたときだけ送る」を実現する代わりに、重複送信を受け入れる設計です。実装の良し悪しはコードの見た目では決まらず、kill -9 のあとに4つの件数が揃うかで決まります。先に試験を書き、Claude Codeには「実行したコマンドと出力を貼る」ことまで頼むと、レビューは出力を読むだけで済みます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. microservices.io/patterns/data/transactional-outbox.html
この記事を共有:XはてブLinkedIn