古いワーカーによる結果の上書きを防ぐ:設計原理
一言でいうと
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つ書いてみてください。