【PR】この記事にはA8.netのアフィリエイト広告を含みます。
先に結論:XServer VPS上のPostgreSQL 17.10で、worker-bが保存した新結果へ古いworker-aが20回遅延書き込みする状態を再現しました。tokenなしでは20/20件が古い結果に上書きされましたが、owner交代ごとに増えるfencing tokenを更新条件に入れると古い20回を全拒否し、新結果20/20件を維持できました。PostgreSQL内の結果保存については条件付きで解決です。
- Task Leaseだけで古いAIワーカーを止められる?
- fencing tokenとは?
- XServer VPSでどんな実験をした?
- 古いworkerが書き戻す順番をどう再現した?
- tokenなしでは何が起きた?
- fencing tokenありでは何が起きた?
- tokenなし・ありの実測差は?
- PostgreSQLの更新条件はどう書く?
- 時計ずれに強い?
- CPU・RAM・ストレージへの影響は?
- 本番サービスと後片付けは?
- fencing tokenだけで二重投稿や二重課金も防げる?
- AIスマホ調査システムへどう組み込む?
- 初心者が迷いそうなポイントは?
- XServer VPSでこの悩みはどこまで解決した?
- よくある質問
- 最新条件の確認先
Task Leaseだけで古いAIワーカーを止められる?
Leaseだけでは不十分です。期限切れになったworkerは、自分がownershipを失ったと知らずに処理を続ける場合があります。新workerが正しい結果を書いた後、旧workerの遅い応答が届くと上書き事故になります。
悩みの起点にしたZenn「複数AIの二重実行を防ぐTask Lease」は、期限付きLeaseと単調増加するfencing tokenを組み合わせ、古いownerの書き込みをresource側で拒否する設計を説明しています。
fencing tokenとは?
ownershipが交代するたびに増える世代番号です。worker-aがtoken 1を持った後、worker-bへ再割当したらDB上のtokenを2へ進めます。worker-aが遅れてtoken 1で書こうとしても、現在値2と一致しないため拒否できます。
PostgreSQL 17のUPDATE公式資料では、WHERE条件が真の行だけが更新され、RETURNINGは実際に更新した行を返します。更新0件はエラーではないため、アプリ側で「stale token」として明示的に扱います。
XServer VPSでどんな実験をした?
| 項目 | 実測条件 |
|---|---|
| VPS | XServer VPS、4 vCPU、4GB RAM、150GB |
| OS | Ubuntu 26.04 |
| Docker | 29.7.2 |
| PostgreSQL | 17.10-alpine |
| job | 各case 20件 |
| container上限 | 0.5 CPU、256MiB RAM、追加swapなし、PID 128 |
| network | none、公開portなし |
本番DBへ障害を入れず、tokenなし・ありで別々の一時PostgreSQL containerを作りました。実験後はcontainerと匿名volumeを削除しています。
古いworkerが書き戻す順番をどう再現した?
- worker-aが20件をclaimし、generation 1を取得。
- lease回収相当の処理でworker-bへ再割当し、generationを2へ更新。
- worker-bが
NEW-Bを20件へ保存。 - 遅れて戻ったworker-aがgeneration 1のまま
OLD-Aを20件へ保存しようとする。
同時実行のばらつきを持ち込まず、問題になる順序を両caseで同一にしました。worker-bの書き込みには両caseともgeneration 2、owner、statusの条件を付け、違いは旧worker-aの書き込み条件だけです。
tokenなしでは何が起きた?
worker-aの古い書き込み20件がすべて成功しました。worker-bのNEW-Bは先に20/20件受理されましたが、その後の無条件更新で最終結果は20件すべてOLD-Aになりました。
さらに最終行はworker_id=worker-b、lease_generation=2なのに、completed_by=worker-a、result=OLD-Aという矛盾した状態です。statusがSUCCEEDEDだから正しいとは判断できません。
fencing tokenありでは何が起きた?
古いworker-aの20回は全拒否されました。DB上はすでにgeneration 2、ownerはworker-b、statusはSUCCEEDEDです。generation 1を含む条件に一致する行がなく、UPDATE ... RETURNINGは0件でした。
拒否した試行もwrite_eventsへaccepted=falseで記録しました。最終結果は20件すべてNEW-B、上書き0件、重複accepted write 0件です。
tokenなし・ありの実測差は?
| 指標 | tokenなし | tokenあり |
|---|---|---|
| worker-bのNEW-B受理 | 20/20 | 20/20 |
| 古いworker-aのOLD-A受理 | 20/20 | 0/20 |
| 古いworker-aの拒否 | 0/20 | 20/20 |
| 最終NEW-B | 0/20 | 20/20 |
| 最終OLD-A | 20/20 | 0/20 |
| 新結果の上書き | 20件 | 0件 |
| 重複accepted write | 20件 | 0件 |
| stale write時間 | 65.472ms | 68.666ms |
PostgreSQLの更新条件はどう書く?
UPDATE jobs
SET status='SUCCEEDED',
result=:result,
completed_by=:worker_id
WHERE id=:job_id
AND lease_generation=:fencing_token
AND worker_id=:worker_id
AND status='IN_PROGRESS'
RETURNING id;
重要なのは、tokenを事前にSELECTしてから別のUPDATEを行わないことです。その間にownerが交代する余地があります。token確認と結果保存を同じstatementの条件に入れ、返却行0件を正常な拒否として処理します。
時計ずれに強い?
generation比較そのものはclient時刻を使いません。workerの時計が多少ずれても、DBに保存された1と2の一致だけで判定できます。
ただし今回はclient時計を意図的にずらしていません。Lease期限を決める処理ではworker送信時刻に依存せず、PostgreSQLのclock_timestamp()などDB側の時計を基準にする必要があります。前回の3秒Lease実験では、期限切れ10件を3.095秒で回収できました。
CPU・RAM・ストレージへの影響は?
| case | cgroup peak RAM | CPU |
|---|---|---|
| tokenなし | 85.43MiB | 2.086078秒 |
| tokenあり | 84.81MiB | 2.060520秒 |
host MemAvailableは約3279.96MiBから3201.85MiB、root disk使用量は57,344 byte増えました。2つのPostgreSQL起動と比較を含む総時間は9.638688秒です。単発・20件の小規模実測なので、大量同時更新時の性能値ではありません。
本番サービスと後片付けは?
公開/health/readyは開始前、各case中、終了後の全4回でHTTP 200でした。database=ok、artifact_storage=okも維持し、本番Caddy、FastAPI、PostgreSQLの停止や再起動はありません。
一時container 2個と匿名volumeを削除しました。dangling volumeは前後0件で、公開portは追加していません。
fencing tokenだけで二重投稿や二重課金も防げる?
防げるとは限りません。今回守れたのは、同じPostgreSQL行への結果保存です。SNS、メール、決済APIなどDB外のresourceがtokenを理解しなければ、送信前にtokenを確認しても、その直後にownerが交代する競合が残ります。
外部作用には、安定したidempotency key、effect receipt、transactional outbox、timeout時のreadbackを組み合わせます。相手APIがtokenを受け付けない場合は、DB側でeffect intentを一意に予約し、結果不明を勝手に再送しない設計が必要です。
AIスマホ調査システムへどう組み込む?
- job claimのたびに
lease_generationを1増やす。 - VPSから自宅PCへjob ID、owner ID、generationを一組で渡す。
- スクリーンショット、操作履歴、料金表示などのuploadにもgenerationを付ける。
- 結果確定はjob ID・generation・owner・statusを同じUPDATEで照合する。
- 拒否数を
stale_token_rejected_totalとして監視する。 - 記事公開などの外部作用はidempotency keyとreceiptを別に持つ。
初心者が迷いそうなポイントは?
- Leaseは「現在の所有者」、fencing tokenは「古い所有者の書き込み拒否」と役割が違う。
- worker名だけでは不十分。同じworker processが再起動しても新generationにする。
- 更新0件はDBエラーではないため、アプリがstale tokenとして判定する。
- tokenをログに残すが、payload本文やsecretは残さない。
- heartbeat延長のたびではなく、ownership取得・再取得でtokenを増やす。
- tokenだけでexactly-onceを断言せず、外部作用の仕組みを分ける。
XServer VPSでこの悩みはどこまで解決した?
PostgreSQL内の結果保存については条件付きで解決です。tokenなしで20件すべて上書きされる危険を再現し、generation条件ありでは古い20件を全拒否できました。本番APIを止めず、4GB環境でも約85MiB peakの隔離DBで検証できています。
一方、実際の同時thread、network partition、同じtokenのretry、外部APIの二重作用までは検証していません。次はidempotency keyとreceiptを含むeffect実行を別実験にします。
よくある質問
Q. fencing tokenはUUIDでもよいですか?
A. 古い・新しいをresource側で比較する用途では、ownershipごとに単調増加する整数が分かりやすいです。UUIDだけでは順序を比較できません。
Q. UPDATEが0件なら失敗ですか?
A. SQL上はエラーではありません。今回のアプリ設計では、RETURNING 0件をstale workerとして拒否記録へ回しました。
Q. status=’IN_PROGRESS’も必要ですか?
A. tokenとownerに加え、許可した状態遷移だけを受理するために入れました。完了済み行への同token再書き込みも防ぎやすくなります。
Q. Lease期限とtokenは同じものですか?
A. 違います。期限は再割当可能になる時点、tokenは再割当前の古いworkerを見分ける世代番号です。
Q. これでexactly-onceになりますか?
A. PostgreSQL行への条件付き更新は守れますが、外部APIまで含むexactly-onceは保証しません。idempotency key、receipt、readbackが必要です。
実験日:2026年9月2日。数値は筆者が契約中のXServer VPS 4GB環境における単発の実測です。契約plan、PostgreSQL version、同時実行数、storage性能により結果は変わります。

コメント