TT Lab
はじめる
学ぶ 学習パス コース

冪等性 — 二度押しても決済は一度だけ

古いワーカーによる結果の上書きを防ぐ:設計原理

TT Labで続きを見る

一言でいうと

SQLiteで、所有権のリースと、増加するfencing tokenを実装します。

なぜ必要なのか

ワーカーAが仕事を受け取ったあと、止まりました。リースが期限切れになるとBが同じ仕事を受け取って完了しましたが、あとから復活したAも完了の結果を書きました。リースの時間を決めるだけでは、古い作業者の書き込みを防げません。保存の時点でも、所有者と世代番号を確認する必要があります。

どう動くのか

jobsは、id・owner・until・fence・result・doneを保存します。claimは、BEGIN IMMEDIATEの中で現在の状態を読み、期限切れの仕事だけを取得して、fenceを増加させます。更新と完了は、現在のownerとfenceがどちらも同じで、リースがまだ有効なときにだけ許可します。exactly-onceの実行は保証しませんが、保存された結果を過去の世代が上書きする経路は、塞ぎます。

claim A fence=1 → 만료 → claim B fence=2 → 완료
              A의 fence=1 완료 ───────────→ 거절

契約を読んで失敗を予測するワークシート

以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。

1. リースの状態テーブルを作る

init_db(path)は、jobs(id TEXT PRIMARY KEY, owner TEXT, until REAL NOT NULL DEFAULT 0, fence INTEGER NOT NULL DEFAULT 0, result TEXT, done INTEGER NOT NULL DEFAULT 0)を、冪等に作成します。

判断の根拠: ワーカーの再起動時に世代番号を初期化すると、古いトークンが再び有効になります。

レビューする誤った変更の断片:

CREATE TABLE jobs

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

2. 最初の仕事だけを登録する

enqueue(path, job_id)は、ないidをデフォルトの状態で入れてTrue、すでにあれば状態を変えずにFalseです。

判断の根拠: 再登録が、進行中のリースを初期化しないように、INSERT OR IGNOREを使います。

レビューする誤った変更の断片:

INSERT OR REPLACE INTO jobs

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

3. 現在の状態を読む

state(path, job_id)は、行をid、owner、until、fence、result、doneのキーのdictで返し、なければNoneです。

判断の根拠: 別のDB接続で保存された状態を読むことで、プロセス内のキャッシュと区別します。

レビューする誤った変更の断片:

if row else {}

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

4. 期限切れのリースをアトミックに取得する

claim(path, job_id, owner, now, ttl)は、ttl<=0ならValueErrorです。ない仕事・完了した仕事・until>nowの仕事はNoneです。それ以外は、ownerとuntil=now+ttlを保存し、fenceを1上げて、新しいfenceを返します。

判断の根拠: 読み取りと更新は、1つのBEGIN IMMEDIATEの中で行います。境界now==untilは、再割り当てが可能です。

レビューする誤った変更の断片:

row[0] >= now

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

5. 現在の世代だけがリースを更新する

renew(path, job_id, owner, fence, now, ttl)は、ttl<=0ならValueErrorです。owner・fenceが一致し、done=0、until>nowのときだけ、until=now+ttlに変えてTrue、そうでなければFalseです。

判断の根拠: すでに期限切れになった所有権をrenewで復活させると、新しいワーカーと衝突します。

レビューする誤った変更の断片:

AND until>=?", (now+ttl

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

6. 古い完了を拒否する

complete(path, job_id, owner, fence, now, result)は、現在のリース(owner・fenceが一致、done=0、until>now)のときだけ、resultとdone=1を保存してTrueです。それ以外はFalseで、既存の結果は保持します。

判断の根拠: 完了の書き込みにもリースの検査があって初めて、遅れて戻ってきたワーカーを防げます。

レビューする誤った変更の断片:

AND done>=0 AND until>?", (result

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

7. 現在のワーカーだけがリースを返却する

release(path, job_id, owner, fence)は、owner・fenceが同じでdone=0の行を、owner=NULL、until=0に変えてTrueです。fenceは保持します。それ以外はFalseです。

判断の根拠: 返却のときにfenceまで初期化すると、過去のトークン番号を再利用することになります。

レビューする誤った変更の断片:

SET owner=NULL,until=0,fence=0 WHERE

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

8. 同時のclaimで勝者は1つだけ

claim_many(path, job_id, owners, now, ttl)は、ThreadPoolExecutorで各ownerのclaimを同時に呼び出し、入力順の戻り値のリストを返します。新しい仕事は、ちょうど1つだけがfenceを得て、残りはNoneである必要があります。

判断の根拠: 事前に参照して接続を閉じてから更新しないでください。ロックが2つの操作を一緒に保護する必要があります。

レビューする誤った変更の断片:

return [claim(path,job_id,owners[0],now,ttl)]

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

現場での姿

時計は、呼び出し側が渡す非減少の数値であり、複数のサーバー間の時計の同期はモデル化しません。業務の外部への副作用まで、自動でfencingされるわけではありません。ストレージの外のシステムにも、同じトークンの検証か、別の冪等処理が必要です。SQLiteは実際のロックとトランザクションを使いますが、大規模な分散キューのスループットを代表するものではありません。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。リースの状態テーブルを作る → 最初の仕事だけを登録する → 現在の状態を読む → 期限切れのリースをアトミックに取得する → 現在の世代だけがリースを更新する → 古い完了を拒否する → 現在のワーカーだけがリースを返却する → 同時のclaimで勝者は1つだけ。

各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。