公開元と解説: https://passquest.signalandship.com/retry-idempotency-experiment
# 再処理で、更新が消える・増える境界を確かめる

PASS QUESTのローカル実験、第1版。2026年9月26日。

同じイベントがもう一度届いたとき、「処理済みIDを保存すれば大丈夫」とは限りません。この実験は処理済み記録と台帳更新の順序を変え、途中で**別のPythonプロセスを実際に終了**させてから同じ入力を再実行します。DBの実ファイルを別の接続で読み、更新が欠ける場合、二重になる場合、1回に保てる場合を比較します。

入力は合成の `demo-event-001`、処理内容は台帳へ `+1` の記録です。実際の注文・支払い・顧客データはありません。

## 今回実際に観測した結果

環境はPython 3.12.0 / SQLite 3.42.0 / Darwin 25.6.0。新しい実行ディレクトリで1バッチを実行し、7ケース・15子プロセス・15回のDB読戻しを保存しました。指定した5か所で子プロセスが終了コード86で中断され、Fでは別の書込プロセスによる `SQLITE_BUSY` を1回観測しました。ケース間で結果をよくするための再試行はしていません。ケース内の再処理は最初から決めた比較手順です。

下表の数は「台帳への反映回数」です。処理済み行数・各行・途中経過は `results.json` と `observations.jsonl` で追えます。

| ケース | 実装と停止位置 | 1回目の後 | 同じ入力を再処理した後 | 分かること |
|---|---|---:|---:|---|
| A | 重複を判定せず更新 | 1 | 2 | 同じ入力でも更新は増える |
| B | 処理済みだけ先に確定して停止 | 0 | 0 | 記録を見てスキップするため、処理が欠けたままになる |
| C | 台帳更新だけ先に確定して停止 | 1 | 2 | 処理済み記録がないため、更新を繰り返す |
| D | 両方を同じトランザクションに入れ、確定前に停止 | 0 | 1 | 未確定更新が残らず、再処理で1回になる |
| E | 両方を確定した直後、終了応答前に停止 | 1 | 1 | 呼出元が成功を知らなくても、同じキーの再処理で更新を増やさない |
| F | 1つ目が未確定の間に2つ目の書込みを試す | 0（未確定・他接続から不可視） | 1（1つ目の確定後）→1（2つ目を明示再処理） | ロック競合の観測と、確定後の重複判定を区別する |
| G | DBの確定前に別ファイルへ追記して停止 | DB 0 / ファイル1行 | DB 1 / ファイル2行 | 同一DBの修正は、トランザクション外の副作用まで原子的にしない |

Gのファイルは通知に見立てた**実ローカルファイル**で、メール送信や外部APIではありません。DB処理が1回になっても、外側の処理は2回になる反例です。

## 配布する証拠の範囲

WebからダウンロードできるのはPythonソース、結果JSON、実行条件のmanifest、時系列ログとこのREADMEです。実DBファイルはダウンロードに含みません。以下のSQL確認は、このコードを自分で再現実行して生成したDBを対象にしています。

## 自分で再現する

Python 3.12以降の標準ライブラリだけを使います。今回確認した実行環境は上記1環境です。他OS/版すべての互換性は確認していません。クラウド、ネットワーク、Docker、追加パッケージ、認証情報は不要です。

新しい空の作業場所へ `retry-boundaries.py` を置き、次を実行します。

```sh
python3 -I retry-boundaries.py --output evidence/my-run
```

`evidence/my-run` が既に存在すると、上書きも再開もせず終了します。実験はこの指定ディレクトリへ合成DBとログを作り、自分が起動した子プロセスだけを終了させます。

成功時の標準出力は次の形です。

```json
{"status":"COMPLETE","casesCompleted":7,"casesPlanned":7,"failure":null}
```

`STOPPED` や期待値不一致は失敗です。途中の観測を残したまま原因を確認してください。期待値を結果に合わせて変えたり、失敗したディレクトリを消して成功だけを報告したりしないでください。

### 出力を読む

1. `manifest.json` で固定入力、実行前の期待値、環境、コードSHA-256を読む。
2. `observations.jsonl` で子プロセスのSQL到達点・終了コード・別接続の読戻しを追う。
3. `results.json` でケース別の全観測と期待値の比較を見る。`counts` は順に `[処理済み行数, 台帳合計, DB外ファイル行数]`。
4. 各ケースの `observations.sqlite` を開き、JSONだけでなく実DBの状態も確かめる。Bでは処理済み1行・台帳0行、Cでは処理済み1行・台帳2行になる。
5. `artifact-hashes.json` でファイルが実行後のままか確認する。日時・OS・SQLiteファイルのバイト列は別環境で異なる場合があるので、再現比較は各ケースの値と操作の順序も照合する。

SQLiteの読取用確認例です。`F-overlapping-workers` など他のケースへ置き換えられます。

```sh
sqlite3 -readonly evidence/my-run/C-effect-first/observations.sqlite \
  'SELECT * FROM processed; SELECT * FROM effects; PRAGMA integrity_check;'
```

Pythonだけで実験を動かす場合、上記のSQLite CLI操作は省略できます。実験自体が別接続による読戻しを保存します。

## 何を学習に使えるか

- 「同じIDがあるか調べる」「処理する」「処理済みにする」の間に、どの失敗位置があるかを書き出す。
- どの操作が同じトランザクションに入るかを囲む。SQSの受信・削除、DB、外部APIを同じ囲みへ勝手にまとめない。
- 外部の副作用には、その処理先の冪等性キーや結果の照合などを別に検討する。DBの一意制約だけで解決したことにしない。
- 再処理の結果と、元の処理を失っていないかを両方確認する。「2回にならなかった」だけではBの欠落を見逃す。

SAAの疎結合・再試行、SAPのサービス分割・ワークフローを学ぶ際に、障害位置を具体化する補助実験です。AWSについての仕様は、AWS公式資料で別に確認してください。このコードはSQLite用であり、DynamoDBの実装例ではありません。

## 確認していないこと

- AWS SQSの配信/再配信、DynamoDB、Step Functions、Lambdaを実行していません。AWSの障害頻度・遅延・復旧時間の測定ではありません。
- 決めた位置でのプロセス終了と、1つの重なる書込条件の実験です。電源断、ディスク故障、分散トランザクション、全ての並行順序は確認していません。
- Fの2つ目はロックで拒否された後、1つ目の確定を見て明示的に1回再処理します。ロック競合そのものを重複排除とは扱いません。
- 同じイベントIDに異なる内容が来る場合、キーの有効期限、処理結果の保存/返却、外部APIのキー仕様、運用監視は対象外です。
- 回数は実験条件で、実務システムの「必ず一度だけ」を保証しません。性能比較・学習効果・合格予測・試験の実問題ではありません。

制作：PASS QUEST。コードと説明は生成AIを用いて作成しました。実行出力はローカルの実処理から保存した観測で、手入力した期待値とは分けています。生成AIを用いた別工程でも、新しい出力先へ再実行し、7ケースの各段階と保存したDBを照合しました。同じ環境での再現確認であり、人による専門家監修や他環境での検証ではありません。
