先に答えを書きます。私は WP-Cron を正確な時計として信じるのをやめました。早く動くこともあれば、何時間も遅れることもある。ひどいときは同じ処理が二回走る。だから直すのではなく、ズレても重複しても取りこぼしても最終的に正しい状態へ収束する土台を、下に敷くことにしました。予約投稿を、そういう組み方に変えた記録です。
きっかけは前作と同じ相手でした。以前、予約メールが夜中だけ届かない件で、WP-Cron はアクセスが無いと動かないことを体で覚えました。今度は予約投稿を作る側です。時刻が来たら投稿する。その「時刻が来たら」を、誰も保証してくれない。予約投稿でそれが狂えば、同じ記事が二度流れる。作る前から、事故の形だけは見えていました。
WP-Cron は時計じゃなく、ときどき来るキックだった
WP-Cron には、よく知られた性質があります。サイトにアクセスが来たときに、たまったタスクをまとめて処理する。だから誰も来ない時間帯はタスクが動かず、アクセスが集中した瞬間に遅れていた分が一気に発火します。時刻ぴったりに動く保証は、最初からありません。だから「直す」方向では考えませんでした。発火を正確にしようとしても、アクセス依存という根っこは変えられない。発想を逆にして、WP-Cron を時計ではなく「ときどき来るキック」として扱うことにしました。いつ来てもいい、二回来てもいい、しばらく来なくてもいい。来たときに、あるべき状態へ一歩進める。その前提で土台を組みます。

土台は三つの層でできています。状態を持つ自前のジョブテーブル、配信アクションを並べるキュー(Action Scheduler)、そして取りこぼしを拾う五分ごとの見回り。中心は一つ目です。ひとつの予約を一行のジョブとして持ち、受付(pending)から予定登録済み(scheduled)、処理中(processing)、送信完了(sent)へと状態を遷移させる。失敗すれば処理待ちに戻し、確定的にだめなら failed で止めます。この一行が、いま各予約がどこにいるかの唯一の真実になります。
Action Scheduler にも状態は記録されますが、それは「アクションを実行したか」であって「予約という業務がどこまで進んだか」ではありません。実行はしたが配信に失敗した、という状態を自分の言葉で持っておきたかった。だから予約の一生は自前テーブルに持ち、Action Scheduler は実行のきっかけを並べる場所と割り切る。どちらが正かを曖昧にすると、障害のときに「どっちを信じればいいのか」で必ず迷います。正を一つに決めておくことが、後々の自分を助けました。
二重投稿は、ロックではなく一行の更新で止めた
いちばん避けたかった事故が、二重投稿でした。SNS の予約投稿で同じ内容が二度流れると、フォロワーのタイムラインに同じ投稿が二つ並ぶ。機能のバグというより、運用者の信用に関わる失敗です。素直に考えると使いたくなるのはロックですが、ロックには弱点があります。鍵をかけたまま処理の途中で PHP が落ちると、鍵が開かないまま残る。そのジョブは誰も触れなくなって永遠に止まる。二重投稿を防ぐ仕組みが、今度は「一度も投稿されない」という別の事故を生む。本末転倒です。
そこでロックは使わず、条件付きの更新を一発だけ撃ちました。状態が scheduled の行だけを processing に書き換える更新です。更新できた行数が一だった呼び出しだけが勝ち、0 行だった側はすでに誰かが処理中にしたということなので、何もせず引き下がる。同じジョブが二回発火しても、先に更新できたほうだけが処理へ進み、もう一方は空振りして戻ります。データベースの行更新が一度に一つしか成立しない、その原子性だけを頼りに二重配信を防いでいます。
鍵をかけて開ける手続きが無いので、途中で PHP が落ちても開きっぱなしの鍵は残りません。状態が処理中で固まるだけで、それは後で見回りが拾う。最悪のケースでも「投稿されない」ではなく「少し遅れて投稿される」に落ちる。失敗の落としどころを、軽いほうへ寄せてあります。下の図の、二重発火しても片方だけが勝つ形が、この設計のいちばんの肝です。このとき処理中にした時刻も記録しておきます。誰がいつその行を取ったか。これが次の固まり検出に効いてきます。

固まりと取りこぼしは、五分ごとの見回りで拾い直す
一行更新でキャッチアップを抑えても、まだ二つの穴が残ります。配信処理中に PHP が落ちてジョブが処理中のまま固まること。予定時刻が来ているのに発火アクションがそもそも積まれていないこと。どちらも WP-Cron のブレが生む現象で、下の図のように五分ごとの見回りで回収します。見回り自身も WP-Cron のループに乗っているので完璧ではありませんが、定期的に状態を点検して、二種類のジョブを拾い直します。

固まったジョブを、死んだとみなして戻す
処理中にしてから十五分を過ぎても処理中のままの行は、途中で落ちたとみなして処理待ちに戻します。さきほど記録した「処理中にした時刻」が判定材料です。配信一回は外部 API の応答待ちを含めても数十秒で終わるのが普通で、それが十五分も終わらないなら、まず途中で死んでいる。短すぎると生きている処理を誤って二重に走らせ、長すぎると死んだジョブを放置する。その境目に十五分を置きました。運用しながら調整する余地のある数字です。
取りこぼした予約を、二重登録せずに拾う
予定時刻を過ぎてもまだ予定登録済みのままで、キューに発火アクションも積まれていない行を探し、改めて登録し直します。ここで、すでに発火アクションが積まれている行には触れないガードを入れています。見回りが重なっても同じ予約を二重に登録しないため。回収の仕組み自体が別の二重を生まないように作る。ここは慎重に組みました。
一時的な失敗は捨てずに、一分後、五分後、十五分後と間隔を広げて再試行します。失敗の多くは相手側の一時的な不調で、すぐ何度も叩き直すと相手の負荷を上げて回復を遅らせる。最初は短く、だめなら少し待ち、それでもだめならもっと待つ。逆に、認証が通らない、リクエストの形式が違うといった何度やっても変わらない失敗は、待たずにすぐ failed にします。失敗を「待てば直るもの」と「待っても直らないもの」に分けて扱う。これが地味に効きます。Action Scheduler が入っていない環境では、黙って壊れるのではなく、動かないことを管理画面で知らせる形にしました。静かに失敗するのが、いちばん怖いからです。
それでも、無アクセスだけは解けなかった
cron のキャッチアップによる二重投稿、配信中のクラッシュで処理が固まること、発火の取りこぼし、一時的なネットワーク失敗、時刻のズレや遅延発火。これらは結果整合の設計で吸収できました。いつ発火しても、最終的には正しい状態へ収束します。ただ一つ、この設計でも救えない場合があります。サイトに誰も来ない、完全な無アクセス状態です。取りこぼしを拾う見回り自身も、結局は WP-Cron のループに乗っている。アクセスが来なければ配信も見回りも両方止まる。土台を支えるものが止まれば、その上の安全網も一緒に止まる。これは構造上、プラグインの中だけでは解けません。

ここから先はサーバー運用側の話です。サーバーの実 cron で wp-cron.php を定期的に叩くか、Action Scheduler を WP-CLI のランナーで回すか。前作で予約メールを Xserver の cron に逃がしたのと、同じところにたどり着きました。アプリケーションでズレと重複と取りこぼしは吸収できる。けれど cron がそもそも一度も回らないケースだけは、アプリケーションの外で担保するしかない。ここは正直に、サーバー cron 推奨にゆだねています。
振り返ると、やったことはぜんぶ同じ考え方の言い換えでした。土台が不確実なら、その上で正確さを取り戻そうとせず、不確実さを受け入れたうえで最終的に正しくなる仕組みを敷く。二重に発火するなら勝者を一つに絞る。処理が固まるなら古いものを死んだとみなして拾い直す。失敗するなら待てば直るものだけ待つ。どれも起きないようにするのではなく、起きても大丈夫にする方向です。起きないようにする設計は起きたときに脆く、起きる前提の設計は起きても崩れない。
最後に前提も正直に書いておきます。状態遷移、一行更新による勝者の確定、見回りによる回収。これらのロジックは、フレームワークに依存しないスモークテストで検証しました。ただ、実トークンを使った本番配信(Qiita などへの実投稿)は、まだ検証できていません。設計とロジックは確かめた、本番配信はこれから。いま立っているのは、そこです。
あなたが時刻どおりの発火を何かに頼っているなら、その時計はどれくらい正確か、そして狂っても落ちない下敷きはあるか、一度だけ確かめてみてください。
検証環境:2026 年 6 月 16 日 / WordPress 7.0 / PHP 8.3.30 / Rapls Relay 0.9.0(開発版)。



コメント