「まだ前の処理が走っているのに、もう次が始まっている」。Dolice Labs で4サイトの記事を毎日決まった時刻に生成している仕組みを眺めていて、ある朝そう気づきました。
きっかけは、Claude Code の API 上限が引き上げられて、それまで余裕を持って空けていた実行間隔を少し詰めたことでした。間隔を 45 分に縮めた直後、たまたま生成が長引いた回があり、前の実行がまだ後片付けをしている最中に、次の cron が新しいプロセスを立ち上げていました。記事が二重に push されかけて、git の履歴を見て手が止まりました。
定期実行は「時刻が来たら走る」だけの素朴な仕組みに見えて、実は前の自分とすれ違う瞬間に弱点を抱えています。今日は、その多重起動をどう抑えるかを、素朴なロックが破れる瞬間からフェンシングトークンまで、私が実際に置いている実装とともに辿っていきます。
なぜ「前を追い越して」次が走り出すのか
cron や Cowork のスケジュールは「この時刻に開始する」ことだけを約束します。「前の実行が終わってから開始する」とは、どこにも書かれていません。
ふだんは生成が間隔より十分速いので、すれ違いは起きません。ところが本番では、API の一時的な遅延、リトライ、巨大な記事の生成、ネットワークの揺らぎ。どれか一つが噛み合うと、一回の実行が間隔を食い破ります。
すると次の起動はためらいなく走ります。両者は同じリポジトリを clone し、同じ slug を選びかけ、同じファイルに書き込もうとします。片方が git push に成功し、もう片方が rebase で衝突する。運が悪いと、わずかに違う本文の記事が二本生まれます。
この問題の核心は、二つの実行が「自分のほかに誰かいる」ことを知らない点にあります。だから、まず互いの存在を知らせる仕組みが要ります。
まず素朴なロックを置く(そして、それが破れる瞬間)
最初に思いつくのは、開始時にフラグを立てて、終了時に下ろす方法です。
// 素朴版 — これは本番では破れます
async function runOnce(store, jobId, body) {
if (await store.read(jobId)) {
return { ran: false, reason: "locked" };
}
await store.write(jobId, { running: true });
try {
await body();
} finally {
await store.write(jobId, null); // 解放
}
}
短時間なら、これで多くのすれ違いは防げます。私も最初はこの形でした。
けれども、二つの穴が残ります。
一つ目は、解放されないロックです。実行が途中でクラッシュしたり、VM ごと落ちたりすると、finally に辿り着けません。フラグは立ったまま残り、翌日からすべての実行が「locked」で弾かれます。無人で回す仕組みでは、これに気づくのが数日後になります。私自身、AdMob のレポートを毎朝取得するジョブで、これに似た固まり方を経験しました。
二つ目は、もっと厄介です。ロックを持っているはずの実行が、実はとっくに死んでいるのに、OS から見ると「まだ生きている」状態。あるいは、長く一時停止していたプロセスが、誰かに見捨てられた後でふと目覚めて、最後の書き込みだけを実行してしまう状態。フラグの有無だけでは、この「ゾンビ」を止められません。
リースとフェンシングトークン — 期限切れを書き込みの手前で止める
この二つの穴をふさぐ古典的な道具が、リースとフェンシングトークンです。分散ロックの文脈でよく知られた組み合わせで、考え方はそのまま定期エージェントに移せます。
リースとは、期限つきのロックです。「いまから ttl ミリ秒のあいだだけ、このジョブはあなたのものです」と貸し出します。期限が来れば、明示的な解放がなくても自動で失効します。これで一つ目の穴、解放されないロックが消えます。クラッシュしても、TTL が過ぎれば次の実行が入れます。
フェンシングトークンは、リースを取るたびに必ず増える番号です。取得のたびに 1 ずつ繰り上がります。これ自体はただの整数ですが、強さは使い方にあります。書き込み先の資源そのものに、「いままで受け入れた最大のトークンより小さい書き込みは拒否する」役割を持たせるのです。
これがゾンビを止めます。期限切れに気づかず遅れて書き込もうとした古い実行は、自分のトークンが小さいために、書き込みの一歩手前で資源側に弾かれます。ロックを「持っているつもり」かどうかは関係ありません。資源が事実として新しい持ち主を知っているからです。
任意ストアの上に最小のリースロックを実装する
まず、ストアに求める性質を絞ります。必要なのは「読み取り」と「条件付きの書き込み(compare-and-set)」の二つだけです。Cloudflare の Durable Objects、Redis、あるいは Postgres の一行を SELECT ... FOR UPDATE で押さえる形。どれでも同じ抽象で書けます。
// lease.ts — 任意の CAS 対応ストアの上で動くリースロック
export interface LockRecord {
owner: string; // 誰が借りているか
token: number; // フェンシングトークン。取得ごとに必ず増える
expiresAt: number; // 失効時刻(epoch ms)
}
export interface LockStore {
read(jobId: string): Promise<LockRecord | null>;
// stored が expected と一致するときだけ next を書く。成功なら true
cas(jobId: string, expected: LockRecord | null, next: LockRecord): Promise<boolean>;
}
export async function acquire(
store: LockStore, jobId: string, owner: string, ttlMs: number, now = Date.now(),
): Promise<LockRecord | null> {
const current = await store.read(jobId);
// まだ有効なリースを別人が持っている → 走らない
if (current && current.expiresAt > now && current.owner !== owner) {
return null;
}
const next: LockRecord = {
owner,
token: (current?.token ?? 0) + 1, // トークンは単調に増やす
expiresAt: now + ttlMs,
};
return (await store.cas(jobId, current, next)) ? next : null;
}
cas を使う理由は、二つの実行がまったく同じ瞬間に取得を試みても、片方しか勝てないようにするためです。読み取りと書き込みのあいだに割り込まれても、expected が食い違えば書き込みは失敗します。ここを単純な write にすると、競り合いの一瞬で両者が成功してしまいます。
リースは長い処理のあいだに失効しないよう、定期的に更新します。
export async function renew(store, jobId, held, ttlMs, now = Date.now()) {
const current = await store.read(jobId);
// 自分のトークンでなくなっていたら、リースを失っている
if (!current || current.owner !== held.owner || current.token !== held.token) {
return null;
}
const next = { ...current, expiresAt: now + ttlMs };
return (await store.cas(jobId, current, next)) ? next : null;
}
更新の間隔は、私は TTL の 3 分の 1 を既定にしています。一度取りこぼしても、失効までに二回の猶予が残る配分です。
副作用の手前でフェンシングトークンを確認する
リースを持っているだけでは、まだ半分です。フェンシングトークンの真価は、後戻りできない書き込み——記事の push や課金の確定——の直前に効きます。
書き込み先には、受け入れた最大のトークンを覚えてもらい、それより小さいトークンの書き込みを拒否させます。
// 書き込み先(push の宛先)側 — ゾンビを事実で弾く
async function acceptPublish(store, resourceId, token: number, commit: () => Promise<void>) {
const seen = await store.read(`fence:${resourceId}`);
if (seen && token <= seen.token) {
return { accepted: false, reason: `stale token ${token} <= ${seen.token}` };
}
// 先にトークンを前進させてから確定する。順序を逆にすると、
// 確定後・記録前のクラッシュで同じトークンが二度通る隙が生まれます
const ok = await store.cas(`fence:${resourceId}`, seen, { owner: "", token, expiresAt: 0 });
if (!ok) return { accepted: false, reason: "raced" };
await commit();
return { accepted: true };
}
実行側は、副作用の直前にもう一度だけリースを読み直し、自分のトークンがまだ最新かを確かめてから宛先を叩きます。
async function publishGuarded(store, jobId, held, doPublish) {
const current = await store.read(jobId);
if (!current || current.token !== held.token) {
throw new Error(`lease superseded: held=${held.token} now=${current?.token}`);
}
await doPublish(held.token); // 宛先で acceptPublish が最終判定する
}
ここで大事なのは、実行側のチェックは早期の安全網にすぎず、最終的な砦は宛先側にある、という分担です。実行側だけで判定すると、読み直した直後・push する直前のわずかな隙に失効したゾンビを、また通してしまいます。資源そのものに門番を置いて初めて、隙が閉じます。
取りこぼした実行をどう畳むか — 上限つきキャッチアップ
多重起動を止めると、逆の問題が顔を出します。VM が長く落ちていた後に復帰すると、その間に逃した実行枠がいくつも溜まっています。これを馬鹿正直に全部走らせると、復帰直後に何本もの記事を一気に生成して、今度は量で品質シグナルを傷つけます。
そこで、キャッチアップには上限を設けます。逃した枠のうち、最新の一つ(または直近の数枠)だけを走らせ、古い枠は静かに捨ててログに残します。
# 取りこぼしの上限つきバックフィル。冪等な実行が前提です
def runs_to_execute(schedule, last_success, now, max_backfill=1):
missed = list(schedule.occurrences(after=last_success, until=now))
if not missed:
return []
skipped = max(0, len(missed) - max_backfill)
if skipped:
log.warning("catch-up: %d 件の古い枠を %s でスキップ", skipped, schedule.job_id)
return missed[-max_backfill:] # 直近の max_backfill 件だけ
記事生成のように「その回でしか得られない鮮度」が薄いジョブは、max_backfill=1 が私の既定です。逃した分を取り戻すより、いまの一本を丁寧に出すほうが、サイト全体の評価には効きます。逆に、課金の確定や顧客への通知のような取り返しの効かないジョブは、上限を上げて、各枠を冪等キー付きで個別に処理します。
運用して見えた数値と、いまの既定値
実際に置いてみての所感を、数字で残します。実行間隔は 45 分、TTL は間隔の約 1.8 倍にあたる 80 分、更新は TTL の 3 分の 1 ごと。これで、多重起動を理由にスキップされる実行は週に 1〜2 回、全実行のおよそ 2% に落ち着きました。前の実行が遅れているときだけ、次が静かに見送られる挙動です。
スキップは失敗ではありません。scheduled.skipped_overlap という別のカウンターに積み、本物のエラーとは混ぜずに観測しています。これを「失敗」に数えてしまうと、健全な見送りでアラートが鳴り続け、やがて誰も見なくなります。
TTL の置き方には、私なりの判断があります。短すぎると、正常な長尺実行のさなかに失効してゾンビ化の窓が開きます。長すぎると、本当にクラッシュした後、次が入れるまで長く待たされます。最悪の正常実行時間を測り、その 1.5〜2 倍を起点に、更新の取りこぼしを見込んで決めるのが、いまの落としどころです。
そして、いちばんの収穫は git の履歴が静かになったことでした。二重 push の気配が消え、push 前のゲートに余計な衝突を持ち込まなくなりました。地味ですが、無人で回す仕組みでは「詰まらない・重ならない」ことそのものが価値になります。
迷ったときに戻る、私の既定値
出発点として、いま私が置いている値を残します。ジョブの性質に合わせて調整してください。
TTL は最悪の正常実行時間の 1.5〜2 倍
普段の実行時間ではなく、過去にいちばん長引いた回を基準にします。ここを普段値で決めると、たまの長尺実行のさなかに失効して、ゾンビの窓が開きます。最悪値起点を私は推奨します。
更新間隔は TTL の 3 分の 1
一度取りこぼしても、失効までに二回の猶予が残る配分です。クラッシュ検知の速さと、無駄な書き込みの少なさが、ちょうど釣り合います。
キャッチアップ上限は鮮度で決める
記事のように鮮度の薄いジョブは 1、課金や通知のように取り返しの効かないジョブは冪等キーを添えて上限を上げる。この使い分けを私は採っています。迷ったら、まず小さい上限から始めるのが安全な対処です。
次の一歩
お使いのストアが本当に compare-and-set を原子的に提供しているか、まずそこだけ確かめてみてください。Cloudflare KV のように結合が緩いストアでは、トークンの単調増加が崩れる隙があります。カウンターには Durable Objects か、トランザクションの効く一行を充てるのが安全です。土台がここで決まります。
同じように夜間や早朝に動く仕組みを育てている方の、設計の足場になればうれしいです。お読みいただき、ありがとうございました。