Найчастіший баг-репорт про WP-Cron звучить однаково: «розсилка мала піти о 9:00, а пішла о 14:30, коли зайшов клієнт». Поломки тут немає: у WordPress немає нічого, що дивиться на годинник. Є список подій у базі й функція, яку запускає звичайний відвідувач сайту.
Як насправді працює WP-Cron
Розклад лежить у серіалізованому масиві в опції cron: ключі верхнього рівня - unix-таймстемпи, далі хуки, далі md5 від серіалізованих аргументів. Тому wp_next_scheduled() не робить окремого запиту в базу, а wp_unschedule_event() вимагає передати рівно ті самі аргументи, з якими подію ставили. Найчастіша причина скарги «подія не видаляється»: під час планування передали ['full'], а під час видалення [].
На кожному фронтенд-запиті на хуку init відпрацьовує wp_cron(). Він бере готові до виконання події і, якщо такі є, викликає spawn_cron(). Той ставить транзієнт-лок doing_cron і робить loopback-запит на /wp-cron.php?doing_wp_cron=<мітка> з 'blocking' => false і 'timeout' => 0.01. Відвідувач при цьому не чекає виконання задач, але саме його запит їх ініціює. Лок живе WP_CRON_LOCK_TIMEOUT секунд (за замовчуванням 60), тому частіше ніж раз на хвилину спроба не повториться.
Звідси три різні за природою проблеми.
Немає трафіку - немає крону. Корпоративний сайт із двадцятьма візитами на добу виконує щогодинні задачі приблизно двадцять разів на добу, у випадкові моменти.
Багато трафіку - надто багато крону. Лок у 60 секунд слабко рятує: якщо одна задача виконується 90 секунд, лок протухає до її завершення, і наступний відвідувач спавнить ще один wp-cron.php. Події при цьому перереєструються до виклику колбека, але паралельний прохід усе одно можливий. Усі колбеки треба писати ідемпотентними, незалежно від того, чим ви запускаєте чергу.
Loopback не проходить. Стейджинг за HTTP Basic Auth, самопідписаний сертифікат, закритий вихідний трафік на 443 порт, DNS, який всередині контейнера резолвиться не туди. wp_remote_post() тихо фейлиться, помилка нікуди не пишеться. Діагностика в один рядок: wp cron test. Якщо проблема лише в авторизації на стейджингу, її лікує фільтр cron_request:
add_filter('cron_request', static function (array $request): array {
$request['args']['headers']['Authorization'] =
'Basic ' . base64_encode(STAGING_USER . ':' . STAGING_PASS);
return $request;
});
Константа ALTERNATE_WP_CRON існує для хостингів без loopback: вона додає doing_wp_cron у URL і редиректить відвідувача так, щоб черга виконалась у його ж запиті. Працює, але ціною редиректу для живої людини й сміття в URL. На час, поки шукаєте нормальний тригер, годиться; залишати таке назавжди не варто.
Ще одна деталь, яка вилазить на великих сайтах: опція cron розпухає. Плагін, що на кожне збереження товару робить wp_schedule_single_event(), легко наганяє туди десятки тисяч записів, і ця опція читається на кожному запиті. Захист від дублів у ядрі є, але слабкий: ідентична подія не ставиться, якщо така сама вже запланована в межах 10 хвилин.
Системний cron замість псевдокрону
Перемикач один:
// wp-config.php, вище рядка "That's all, stop editing!"
define('DISABLE_WP_CRON', true);
Після цього wp_cron() виходить на початку і loopback не спавниться. Файл wp-cron.php при цьому лишається публічно доступним, його може смикати хто завгодно, і константа цього не змінює.
Запускати краще через WP-CLI, а не через curl на wp-cron.php. CLI SAPI не має max_execution_time, не займає воркер PHP-FPM, дає код виходу для моніторингу і не потребує відкритого HTTP-ендпоїнта. Тоді wp-cron.php можна закрити на рівні nginx (location = /wp-cron.php { deny all; }) і забути про нього.
* * * * * /usr/bin/flock -n /run/lock/wp-cron.lock \
/usr/local/bin/wp cron event run --due-now --quiet --path=/var/www/site \
>> /var/log/wp/cron.log 2>&1
flock -n тут обов'язковий: без нього довга задача зустрінеться з наступним запуском і ви отримаєте ту саму гонку, від якої тікали. Раз на хвилину цілком нормальна частота; --due-now бере лише те, що справді настало.
Для мультисайту одного виклику мало, у кожного сайту своя опція cron:
for url in $(wp site list --field=url --path=/var/www/site); do
wp cron event run --due-now --quiet --path=/var/www/site --url="$url"
done
Далі про реєстрацію подій. Класична порада вішати wp_schedule_event() на register_activation_hook() ламається на першому ж деплої, який не перевмикає плагін: подія зникла після ручного wp cron event delete або після відкату бази, а активації більше не буде. Надійніше перевіряти на кожному запиті, це дешево:
add_action('init', static function (): void {
if (wp_next_scheduled('myplugin_sync_prices')) {
return;
}
$scheduled = wp_schedule_event(time() + 300, 'hourly', 'myplugin_sync_prices', [], true);
if (is_wp_error($scheduled)) {
error_log('[myplugin] schedule failed: ' . $scheduled->get_error_message());
}
});
Третій аргумент $wp_error з'явився у WP 5.7 і повертає причину замість голого false. Поруч живуть екшени cron_reschedule_event_error і cron_unschedule_event_error - єдине, що ядро взагалі повідомляє про проблеми з розкладом.
Свої інтервали додаються фільтром cron_schedules, бо базових лише чотири (hourly, twicedaily, daily, weekly):
add_filter('cron_schedules', static function (array $schedules): array {
$schedules['every_five_minutes'] = [
'interval' => 300,
'display' => 'Кожні 5 хвилин',
];
return $schedules;
});
Action Scheduler для довгих задач
Системний cron лікує тригер, але не архітектуру. WP-Cron лишається однією чергою без пріоритетів, без статусів, без історії, з аргументами у спільній опції. Як тільки задача перестає бути «сходи в API і онови три поля», починаються проблеми: переіндексувати 40 000 товарів одним хуком не вийде ні за яким таймаутом.
Action Scheduler - бібліотека від Automattic, яка їде разом із WooCommerce, але ставиться і окремо через Composer (woocommerce/action-scheduler). Вона тримає власні таблиці (actionscheduler_actions, _logs, _groups, _claims), має статуси pending, in-progress, complete, failed, canceled і адмінку в «Інструменти → Заплановані дії».
Правило просте: розбивайте велике на шматки й передавайте ідентифікатори, а не самі дані.
function myplugin_enqueue_reindex(array $post_ids): void {
foreach (array_chunk($post_ids, 50) as $chunk) {
as_enqueue_async_action('myplugin_reindex_chunk', [$chunk, 1], 'myplugin-reindex');
}
}
as_enqueue_async_action() ставить дію «на найближчий прохід», as_schedule_single_action() і as_schedule_recurring_action() - на конкретний час та з інтервалом у секундах, as_schedule_cron_action() приймає cron-вираз. Група (myplugin-reindex) дозволяє потім зняти все одним as_unschedule_all_actions(null, null, 'myplugin-reindex') і зручно фільтрувати в адмінці. Перед плануванням є сенс перевірити as_has_scheduled_action(), щоб не наплодити дублів.
Обробник отримує аргументи позиційно, тому accepted_args треба виставити явно:
add_action('myplugin_reindex_chunk', 'myplugin_reindex_chunk_handler', 10, 2);
Черга Action Scheduler усе одно потребує зовнішнього тригера: вона реєструє власну подію action_scheduler_run_queue у WP-Cron. На проді краще обійти цей місток і запускати runner напряму, окремим завданням:
*/1 * * * * /usr/bin/flock -n /run/lock/as.lock \
/usr/local/bin/wp action-scheduler run --batch-size=25 --path=/var/www/site --quiet
Дефолтний прохід черги обмежений 30 секундами (action_scheduler_queue_runner_time_limit) і 25 діями за партію (action_scheduler_queue_runner_batch_size). Піднімати ліміти варто після того, як ви впевнилися, що одна дія виконується за секунди. Механізм claims не дає двом runner'ам взяти ту саму партію, тож паралельні воркери через --concurrency безпечні щодо дублювання, але не щодо навантаження на базу.
Логування й повторні спроби
Action Scheduler фіксує факт виконання, але сам не повторює невдалі дії. Дія, яка кинула виняток, стає failed і лежить так, доки її не перезапустять руками. Retry треба писати самому, і найпростіший спосіб - лічильник спроб в аргументах плюс експоненційна пауза:
function myplugin_reindex_chunk_handler(array $post_ids, int $attempt = 1): void
{
try {
myplugin_reindex($post_ids);
} catch (RuntimeException $e) {
if ($attempt >= 5) {
throw $e; // нехай падає у failed: далі це вже інцидент, а не мережевий збій
}
as_schedule_single_action(
time() + (2 ** $attempt) * 60,
'myplugin_reindex_chunk',
[$post_ids, $attempt + 1],
'myplugin-reindex'
);
}
}
Виняток після п'ятої спроби навмисний: failed зі стектрейсом у логах дії видно в адмінці, а тихо проковтнута помилка не видна ніде.
Свої записи в лог дії додаються через логер:
ActionScheduler_Logger::instance()->log($action_id, sprintf('reindexed %d posts', count($post_ids)));
Глобальні спостерігачі підключаються екшенами. action_scheduler_failed_execution спрацьовує на виняток у колбеку, action_scheduler_failed_action - коли дія зависла в in-progress довше за action_scheduler_failure_period (5 хвилин за замовчуванням, тобто воркера вбили посеред роботи):
add_action('action_scheduler_failed_execution', static function (int $action_id, Throwable $e): void {
error_log(sprintf('[as] action %d failed: %s', $action_id, $e->getMessage()));
}, 10, 2);
add_action('action_scheduler_failed_action', static function (int $action_id, int $timeout): void {
error_log(sprintf('[as] action %d timed out after %ds', $action_id, $timeout));
}, 10, 2);
З WP-Cron ситуація гірша: подій «виконалось» чи «впало» там немає взагалі. Усе, що лишається, - обгорнути власні колбеки і писати початок, кінець та тривалість самостійно. Фатал в одному колбеку обриває решту партії поточного проходу; повторювані події при цьому вже перереєстровані наперед, а ось одноразові з тієї ж партії просто зникають.
Щодо моніторингу. wp cron event list --format=count разом із порівнянням next_run проти now показує, чи черга не встає: якщо є події, прострочені більш ніж на 15 хвилин, тригер зламався. Для Action Scheduler ту саму роль виконує wp action-scheduler status - кількість pending, що стабільно росте, або ненульовий failed - привід для алерту. Прибирання старих записів там налаштоване фільтром action_scheduler_retention_period (30 днів), і на сайтах із мільйонами дій на місяць його треба скорочувати вручну, інакше таблиця логів переростає всю решту бази.