やりたかったことは、とても簡単だった。
新しい記事を書いたら、なるべく早く公開する。
それだけである。
ところが気づけば、公開処理の前に「今回は誰が担当する?」「Workersか? Actionsか?」「予算は?」「前回のleaseは?」「markerは?」「再開回数は?」と会議が始まっていた。
記事は待っている。
システムも待っている。
全員が「自分の担当ではない」と思っている。
会社なら会議室に呼び出せるが、分散システムは黙って止まる。
これが、Live Schedule地獄である。
1. やりたいのは「即時公開」なのに、いつの間にか担当者選挙をしていた
公開パイプラインを強くしようとすると、自然に保険を増やしたくなる。
Workersが止まったらActionsへ。 Actionsの予算が厳しければWorkersへ。 二重実行しないようleaseを置く。 同じ仕事を二人が触らないようmarkerを置く。 失敗したら再開回数を数える。 監視役も付ける。
一つずつ見ると、全部正しい。
問題は、これらを直列につないだ瞬間である。
「記事を公開できるか」より前に、「誰が公開する資格を持つか」が巨大な状態機械になる。
そして一番危険なのが、担当者札だけが生き残る状態だ。
Workers側の処理は止まっている。 しかし状態には「Workers担当」と残っている。 次にActionsが起動しても、「Workers担当なので自分はスキップ」と判断する。
誰も壊れていない。 誰も仕事をしていない。
分散システム版「担当者は席を外しております」である。
2. 失敗の本体は実行能力ではなく、coordinationだった
ある実行では、Actions側に処理能力も予算も残っていた。
それでも本体処理は走らなかった。
理由は単純で、「その仕事はWorkers担当」という判定だったからだ。
ここが重要である。
CPUが足りないわけではない。 APIが全部死んでいるわけでもない。 Actionsが起動できないわけでもない。
処理能力ではなく、担当調整が止めている。
こうなると、個別バグを修正しても終わりにくい。
データ取得量を直す。 main反映失敗を直す。 再試行を直す。 担当切替を直す。 今度は古いmarkerが残る。 次はleaseが残る。 監視は検知するが、監視役には公開権限がない。
モグラ叩きに見えるが、モグラの発生源は同じである。
control planeが賢すぎる。
3. scheduleは時計であって、仕事キューではない
GitHub Actionsのscheduleは便利だが、公式ドキュメントでも高負荷時に遅延する可能性があり、負荷が十分高い場合にはキュー済みジョブの一部が落ちる可能性まで明記されている。
つまりscheduleは「だいたいこの時間に起こしてほしい」には向く。
しかし「この原稿は必ず誰かが拾い、成功するまで持ち続けてほしい」という責任まで背負わせると、役割が違ってくる。
時計は鳴る。
時計は原稿を抱えてくれない。
一方、メッセージキューは仕事そのものを保持し、retryやdelay、dead-letter queueのような失敗処理を持てる。
ここで大事なのは「絶対に新しいサービスを入れろ」という話ではない。
設計上、
- schedule = 定期的に起こす仕組み
- pending = 未完了仕事の正本
- executor = 実際に仕事をする人
- lease = 同時実行を防ぐ鍵
- retry / DLQ = 失敗した仕事の行き先
と役割を分けることである。
時計に人事部と倉庫と遺失物センターを兼務させると、だいたい時計が泣く。
4. 「誰がやる?」より「何がまだ終わっていない?」を正本にする
複雑な担当割当では、状態の中心が「担当者」になりやすい。
しかし公開パイプラインで本当に知りたいのは、
この記事は公開済みか、未公開か。
ほぼこれだけである。
だから正本はpendingでいい。
新規原稿が入ったらpending。 公開開始時に短いlease。 成功したらpublished。 失敗したらpendingに戻す。 規定回数失敗したらdead-letter相当へ移して警告する。
Workers担当、Actions担当という情報は補助情報であって、永久的な拒否権にしない。
「Workers担当」と書かれていても、一定時間進捗がなければ別executorが回収できる。
これなら、死んだ担当者札が仕事を人質に取れない。
5. 本線はevent-driven、scheduleは取りこぼし回収でいい
即時公開したいのに、次のcronまで待つのは本末転倒である。
新規原稿が到着した瞬間にexecutorを起こす。
これを本線にする。
そしてscheduleは、数分から数十分たっても残っている古いpendingを拾う。
つまり、
イベント駆動が本線、scheduleは保険。
この順番なら、普段は速い。
イベント通知を取りこぼしても、scheduleが後から拾う。
逆にscheduleを本線にすると、遅延した時点で公開そのものが遅れ、さらに担当判定まで入ると「起きたのに何もしない」が発生する。
cronが起きた。
gateも通った。
そして「今日は私の担当ではありません」と帰宅する。
お前は何しに来たんだ。
6. fallbackは「担当変更」ではなく「古い未完了の回収」にする
fallback設計でありがちな罠は、AからBへ正式に担当を移すことだ。
担当移管には状態更新が必要になる。
その更新が失敗すると、今度は移管そのものが障害になる。
もっと単純でいい。
通常はWorkersが処理する。 成功すれば終了。 失敗すればpendingが残る。
Actionsなどのfallback executorは定期的に、
- pendingがある
- 一定時間以上古い
- 有効なleaseがない
- 自分が実行可能
なら回収する。
「Workersから譲渡された証明書」がなくてもいい。
見るべきは仕事が終わっていない事実である。
この方式では、担当表の整合性より成果物の有無が強い。
記事公開システムなら、その方が自然だ。
7. Live Scheduleは捨てなくていい。ただし生命維持装置にしない
Live Schedule自体が悪いわけではない。
人間が見ている時に優先順位を変える。 異常を調べる。 改善案を作る。 修理を加速する。 新しい企画を投入する。
こういう用途には非常に強い。
問題は、それが止まった瞬間に記事工場まで止まることである。
Liveはacceleratorであって、single point of failureにしない。
Liveがいなくても、
新規原稿 → pending → executor → validation → publish
が自律して回る。
Liveが来たら速くなる。
いなくても死なない。
人間の心臓に「担当者のSlack返信」を接続してはいけないのと同じである。
8. 終了条件を「賢い仕組み完成」から「1本入れたら1本出る」に戻す
この手のシステムは、改善しようと思えば永遠に改善できる。
公平な担当割当。 無料枠最適化。 重複防止。 予算予測。 優先順位。 監視。 自動復旧。 分析。
全部面白い。
そして全部やると、記事が出ない。
だから最初のDefinition of Doneはもっと雑でいい。
新しい原稿を1本入れたら、特別操作なしで1本公開される。
失敗してもpendingに残る。 別executorが回収する。 何度も失敗したものだけ人間に上げる。
この単純な一本線が安定してから、最適化を戻せばいい。
「新規記事をすぐ出したい」だけだったのに、気づけば分散合意っぽいものを自作していた。
そういう日はある。
でも終わらせる方法はある。
複雑性を切る。
公開システムの価値は、担当者選挙の美しさではない。
記事が外に出ることだ。
結論
Live Schedule地獄の本質は、scheduleそのものではない。
「仕事の未完了状態」より「担当者の状態」を強くしすぎたことである。
設計を戻すなら、
- pendingを未完了仕事の正本にする
- event-drivenで即時起動する
- leaseは短くする
- fallbackは古いpendingを回収する
- scheduleは保険にする
- 規定回数失敗はdead-letter相当に隔離する
- Liveは任意の加速役にする
このくらいでいい。
賢い工場より、止まらない工場。
まずはそこからである。
資料
- GitHub Docs: Events that trigger workflows — schedule https://docs.github.com/actions/reference/workflows-and-actions/events-that-trigger-workflows
- Cloudflare Workers Docs: Cron Triggers https://developers.cloudflare.com/workers/configuration/cron-triggers/
- Cloudflare Queues Docs: Overview https://developers.cloudflare.com/queues/
- Cloudflare Queues Docs: Dead Letter Queues https://developers.cloudflare.com/queues/configuration/dead-letter-queues/
