Планировщик задач

Регулярные фоновые работы — загрузка прайсов поставщиков, выгрузка товаров в торговые площадки, пересчёт статистики, рассылка напоминаний, очистка кеша — в Melbis выполняются не консольными скриптами, а обычными модулями платформы, вызываемыми по HTTP.

Почему через HTTP, а не из командной строки

Консольный запуск PHP-скрипта живёт в другом окружении, чем витрина: свои настройки интерпретатора, свой пользователь, нет ни $_SERVER, ни сессии, ни привычного окружения запроса. Из-за этого возникает классическая ловушка: в браузере скрипт отрабатывает, а по расписанию падает — или наоборот.

Планировщик Melbis устроен так, что фоновая задача — это тот же самый модульный скрипт, который разработчик пишет и отлаживает в IDE, открывая его в браузере. Он выполняется тем же кодом, с тем же кешем, с теми же подключёнными библиотеками. Открыли модуль в браузере, добились нужного результата — ровно это же произойдёт и ночью по расписанию.

Схема работы

Системный crontab (хост)              Контейнер melbis_shop
   |                                     |
   |── раз в минуту ───────────────────> |
   |   docker exec ... php cron.php      |── cron.php
   |                                     |    ├─ CronAdd(...)  — объявление задач
   |                                     |    └─ CronRun()     — что запускать сейчас
   |                                     |         |
   |                                     |         └── curl → http://localhost/?mod=...
   |                                     |                      |
   |                                     |                      └─ Apache того же контейнера
   |                                     |                           └─ index.php → модуль задачи

Ключевая деталь: curl бьёт в localhost внутри того же контейнера, минуя внешний прокси. Из этого следуют два важных свойства:

Требование к развёртыванию. Различие адресов работает, пока веб-сервер проекта и прокси живут в разных сетевых пространствах. Если прокси когда-нибудь окажется в том же контейнере или будет запущен с network_mode: host, защита CronLocalOnly перестанет отличать посетителя от планировщика — молча.

Установка

При установке стандартным инсталлятором всё уже настроено: в системный crontab добавлена строка

* * * * * /usr/bin/docker exec melbis_shop php /var/www/html/cron.php > /dev/null 2>&1

а в корне проекта лежит cron.php. Прямой доступ к нему из браузера закрыт правилом .htaccess (RewriteRule ^cron.php$ - [F]).

Системный crontab вызывает скрипт каждую минуту и больше не меняется никогда: всё расписание проекта живёт внутри cron.php, а конкретная работа — в модулях. Лезть в crontab на сервере не нужно.

Объявление задач

<?php
// cron.php

require 'units/melbis.php';

// Tasks
MELBIS()->CronAdd('*/5 * * * *', 'http://localhost/?mod=melbis_cron_price', 240, 'price');
MELBIS()->CronAdd('0 3 * * *',   'http://localhost/?mod=melbis_cron_clearing', 600);

// Run
MELBIS()->CronRun();

?>

CronAdd($mTiming, $mUrl, $mTimeout = 300, $mLogName = false)

CronRun() сверяет расписания с текущей минутой и запускает подошедшие задачи. Если задач несколько, они уходят параллельно и CronRun ждёт завершения всех — то есть медленная задача не задерживает старт остальных, но задерживает завершение самого cron.php.

Время сверяется в часовом поясе проекта (MELBIS_TIME_ZONE), а не в поясе сервера.

Формат расписания

Пять полей, разделённых пробелами:

 ┌───────────── минута       0-59
 │ ┌─────────── час          0-23
 │ │ ┌───────── день месяца  1-31
 │ │ │ ┌─────── месяц        1-12
 │ │ │ │ ┌───── день недели  0-7   (0 и 7 — воскресенье)
 │ │ │ │ │
 * * * * *

Каждое поле принимает:

Запись Значение
* любое значение
5 ровно 5
1,15,30 список значений
9-18 диапазон
*/10 шаг по всему диапазону поля
1-20/3 шаг внутри диапазона

Шаг отсчитывается от начала диапазона поля, а не от нуля. Это то же правило, что и в системном cron, но оно часто удивляет: */2 в поле дня месяца означает 1, 3, 5 … 31-е число, а не чётные числа. Причина в том, что * — это просто сокращение для полного диапазона поля, а диапазон дня начинается с единицы. Для минут и часов, которые начинаются с нуля, разницы не видно.

День месяца и день недели объединяются через ИЛИ, если ограничены оба. Расписание 0 0 13 * 5 сработает 13-го числа или в пятницу — а не только в пятницу 13-го. Если одно из двух полей равно *, всё работает обычным И. Правило досталось от системного cron и существует потому, что живые расписания обычно звучат как «1-го числа и по понедельникам».

Примеры:

* * * * *        каждую минуту
*/15 * * * *     каждые 15 минут
0 * * * *        в начале каждого часа
30 3 * * *       ежедневно в 03:30
0 9-18 * * 1-5   каждый час с 9 до 18 по будням
0 4 1 * *        1-го числа каждого месяца в 04:00
0 2 */7 * *      каждое 1, 8, 15, 22 и 29-е число в 02:00

Отличия от системного cron

Модуль задачи

Cron-модуль — обычный модульный скрипт с одним отличием: в параметрах модуля в IDE должен быть включён флаг «Модуль входной точки», иначе парсер откажется запускать его по прямому обращению.

function MELBIS_CRON_PRICE($mVars)
{
    // Вывод в журнал, а не в браузер
    header('Content-Type: text/plain; charset=utf-8');

    // Только локальный вызов
    MELBIS()->CronLocalOnly();

    // ... работа ...
    echo "Обработано товаров: $count\r\n";

    return '';
}

CronLocalOnly() пропускает запрос только с 127.0.0.1 или ::1, а любой другой обрывает с кодом 403 и текстом Only local allowed. Перед выходом метод снимает блокировку компиляции кеша — если оборвать выполнение обычным die, блокировка останется, и следующий запрос к этому модулю будет впустую ждать несуществующую компиляцию.

Ставьте вызов первой строкой после заголовка, до любых запросов к базе: задача, доступная снаружи, — это открытая возможность запускать тяжёлую работу на вашем сервере сколько угодно раз.

На время отладки строку удобно закомментировать, чтобы открывать модуль из браузера, и вернуть перед выкаткой.

Всё, что модуль печатает через echo, попадает в журнал задачи — поэтому осмысленный текстовый отчёт полезнее молчания.

Наложение запусков

Планировщик не мешает задаче запуститься второй раз, пока не завершилась первая. Если задача с расписанием */5 * * * * работает семь минут, через пять минут она будет запущена повторно, параллельно с ещё не закончившейся.

Это сделано намеренно: принудительная блокировка означала бы, что упавшая задача оставляет замок, который надо как-то снимать по таймауту, — а это источник сюрпризов худших, чем сама проблема. Ответственность за поведение задачи лежит на её авторе.

Рабочий приём — самоограничение по времени: задача обрабатывает данные порциями и выходит, не дожидаясь конца работы, если исчерпала отведённое время. Недоделанное подхватит следующий запуск.

$start = MELBIS()->RunTime();
foreach ( $rows as $row )
{
    // ... обработка одной записи ...

    $time = MELBIS()->RunTime($start);
    echo 'ID: '.$row['id'].' Time: '.$time."\r\n";
    if ( $time > 100 )
    {
        echo "Abort, time limit\r\n";
        break;
    }
}

Порог выбирается заведомо меньшим, чем период расписания, — тогда очередной запуск гарантированно застанет предыдущий завершённым. Заодно это защищает от $mTimeout: задача сама решает, где остановиться, вместо того чтобы быть оборванной на середине.

Журналы

Имя журнала передаётся четвёртым аргументом CronAddбез пути и без расширения:

MELBIS()->CronAdd('*/5 * * * *', 'http://localhost/?mod=melbis_cron_price', 240, 'price');

Записи уйдут в core/log/cron/price.log. Всё лишнее — каталоги, расширение — отбрасывается: аргумент задаёт только имя файла внутри core/log/cron/. Задачи могут писать в общий журнал или каждая в свой — достаточно передать одно и то же имя или разные.

В журнал пишется строка с временем запуска, адресом, кодом ответа и временем выполнения, а следом — весь вывод модуля.

Платформа журналы не чистит. Размер не ограничен, старые записи не удаляются: кто включил журнал, тот за ним и следит. В стандартной установке этим занимается системный logrotate, которому отдана вся папка core/log — он режет файлы посуточно, сжимает и хранит неделю. На нестандартном развёртывании об этом надо позаботиться самостоятельно.

Ошибки

Если задача не ответила или вернула код не из диапазона 2xx, планировщик сообщает об этом через штатный механизм ошибок проекта с типом Cron Error — то есть запись попадает в core/log/melbis/front.log рядом с остальными ошибками платформы. Это происходит независимо от того, задан журнал задачи или нет, так что молча падающая задача не останется незамеченной.

Напомним, что запись в этот журнал включается наличием файла error.save в корне проекта (см. «Журналы, ошибки и метрики»). Без него сообщение никуда не попадёт.

Чего планировщик не делает