冪等キーを消すと保証も終わる:設計原理
一言でいうと
完了の記録と進行中の記録を区別し、保管期間とバッチでの整理を実装します。
なぜ必要なのか
テーブルが大きくなったので、古い冪等キーをすべて削除しました。まだ決済中のリクエストの記録も消え、リトライが新しいリクエストとして入ってきました。完了した応答の保管期間と、進行中の作業の所有権は、同じ期限切れのポリシーではありません。整理は、単純なDELETEではなく、保証の範囲を変える状態遷移です。
どう動くのか
キーはpendingとして予約し、同じ本文のフィンガープリントでだけdoneに変えます。完了した応答は、expiresの時刻の前まで再生します。整理は、doneでexpires以下の行だけを、id順とlimitで制限して消します。pendingは、どれだけ古くても、この整理関数は削除しません。最後のラボは、整理の前後で同じキーを予約してみて、記録の削除後は新しいリクエストとして扱われるという限界を、直接確認します。
pending → done → expires 도달 → 제한된 purge → 같은 키가 새 요청이 됨
pending ─────────────────→ purge 대상 아님
契約を読んで失敗を予測するワークシート
以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。
1. 完了の有無と期限切れを分ける
init_db(path)は、keys(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,status TEXT NOT NULL,response TEXT,expires REAL NOT NULL)を、冪等に作成します。
判断の根拠: 期限切れの時刻だけを見て、業務が終わったと判断しません。
レビューする誤った変更の断片:
CREATE TABLE keys
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
2. 進行中のキーを予約する
reserve(path,key,digest,expires)は、ないキーをpending、response=NULLで入れてTrueです。既存のキーは、期限切れに関係なく、変更せずにFalseです。
判断の根拠: 記録の削除とキーの再利用は、分離して、明示的に制御します。
レビューする誤った変更の断片:
INSERT OR REPLACE INTO keys
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
3. 現在のリクエストだけを完了する
finish(path,key,digest,response)は、一致するkey・fingerprintで、pendingの行だけに、responseをJSONとして保存してdoneに変え、Trueです。それ以外はFalseです。
判断の根拠: 別の本文の作業者が、完了の応答を上書きできないように、フィンガープリントまで比較します。
レビューする誤った変更の断片:
AND status IN ('pending','done')
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
4. 有効な完了の応答だけを再生する
fetch(path,key,now)は、doneでexpires>nowの行のresponseをJSONとしてパースして返します。それ以外はNoneです。
判断の根拠: now==expiresの境界で、もう再生しないという契約を固定します。
レビューする誤った変更の断片:
AND expires>=?
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
5. 整理の候補を制限する
expired(path,now,limit=10)は、boolを除くintの1–100であるlimitを検査します。doneでexpires<=nowのidを、id昇順で最大limit個返します。
判断の根拠: テーブル全体を一度に消すと、ロックの時間が長くなり、進行中の行を巻き込みやすくなります。
レビューする誤った変更の断片:
return [r[0] for r in db.execute("SELECT id FROM keys WHERE status='done' AND expires<=? ORDER BY id DESC
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
6. 選択と削除を同じトランザクションに置く
purge(path,now,limit=10)は、expiredと同じlimitの検証・条件・ソートで候補を選んだあと、1つのトランザクションで削除して、削除したidのリストを返します。pendingは消しません。
判断の根拠: 候補の取得と削除の間に、状態が変わる隙間を作りません。
レビューする誤った変更の断片:
ids=[r[0] for r in db.execute("SELECT id FROM keys WHERE expires<=?
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
7. 状態ごとの個数を実際に数える
counts(path)は、{pending:個数, done:個数}です。該当する状態がなくても、キーと0が必要です。
判断の根拠: 整理のあとに、未完了の行が消えていないかを観測できる必要があります。
レビューする誤った変更の断片:
"pending":0
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
8. 削除のあとに保証の範囲が終わることを確認する
retention_cycle(path,key)は、expires=10、digest='v1'で予約して、{receipt:1}で完了します。now=10でpurgeしたあと、同じキーをdigest='v2'、expires=20で新しく予約して、その結果のboolを返します。関数は、空のDBを前提とします。
判断の根拠: 整理のあと、キーが新しいリクエストになるという限界を隠さず、直接再現します。
レビューする誤った変更の断片:
purge(path,9)
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
現場での姿
無期限のexactly-onceの効果は保証しません。外部システムは古いリクエストを再送することがあるので、保管期間は、プロバイダーのリトライポリシーと合わせる必要があります。abandoned pendingを復旧するリース・補償のポリシーは、別のラボの責任であり、この整理関数では、推測して消しません。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。完了の有無と期限切れを分ける → 進行中のキーを予約する → 現在のリクエストだけを完了する → 有効な完了の応答だけを再生する → 整理の候補を制限する → 選択と削除を同じトランザクションに置く → 状態ごとの個数を実際に数える → 削除のあとに保証の範囲が終わることを確認する。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。