Планировщик
Планировщик запускает ваши методы по расписанию: каждые несколько секунд, каждую
ночь в три часа, по выражению cron. Достаточно пометить метод атрибутом — искать
его, заводить отдельный скрипт и прописывать строку в системном crontab не нужно.
Что такое планировщик и зачем
Планировщик — компонент приложения, который сам вызывает помеченные методы в нужные моменты времени.
Проблема. Регламентные работы есть почти в любом сервисе: подчистить
просроченные записи, разослать напоминания, свести отчёт за сутки, опросить
внешний источник. Классический способ — системный cron и отдельный
скрипт-обёртка на каждую задачу. Плата за это:
- расписание живёт вне проекта, в конфигурации сервера, и в код-ревью не попадает;
- каждый скрипт поднимает PHP и приложение заново — контейнер, конфигурацию, подключения;
- никто не следит за наложением: если прошлый запуск ещё идёт,
cronспокойно запустит второй; - минимальный шаг — минута, чаще нельзя.
Решение. Winter держит расписание рядом с кодом. Метод помечается атрибутом, планировщик поднимается один раз и вызывает задачи внутри уже загруженного приложения — с готовым контейнером и открытыми подключениями. Наложение исключено, а шаг может быть меньше секунды.
Механизм повторяет @Scheduled из Spring: те же три способа задать каданс —
fixedRate, fixedDelay и cron, — с той же семантикой. Если вы с ним работали,
дальше вас ждут в основном подробности про cron-выражения и поведение при долгих
прогонах.
Быстрый старт
Три шага: пометить метод, включить компонент, запустить.
<?php
namespace Main\Task;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
use Psr\Log\LoggerInterface;
class Cleanup
{
#[Autowired] private SessionRepository $sessions;
#[Autowired] private LoggerInterface $logger;
#[Scheduled(fixedRate: 300)] // каждые 5 минут
public function purgeExpiredSessions(): void
{
$removed = $this->sessions->deleteExpired();
$this->logger->info('sessions purged', ['count' => $removed]);
}
}Включаем компонент в манифесте приложения:
#[EnableWeb]
#[EnableScheduler]
final class Application extends WinterApplication { /* ... */ }Проверяем, что задача найдена, и запускаем:
php call schedule list
| TASK TRIGGER
| Main\Task\Cleanup::purgeExpiredSessions fixedRate 300s
| 1 task(s) defined.
php call run # планировщик поднимется вместе с приложениемРегистрировать класс нигде не нужно — его находит тот же скан, что находит контроллеры.
Требования к методу
Планировщик создаёт объект через контейнер и вызывает метод без аргументов. Отсюда четыре условия:
| Требование | Почему |
|---|---|
Метод public |
Иначе он не виден при обходе |
Метод не static |
Объект создаётся контейнером, вызов идёт на экземпляре |
| Без обязательных параметров | Передавать в метод нечего |
| Класс можно создать | Не абстрактный, не интерфейс |
Зависимости внедряются как обычно — через конструктор или #[Autowired]; работает
всё, что описано во Внедрении зависимостей.
Нарушение любого условия — ошибка при запуске, а не молчаливый пропуск:
ScheduleConfigException:
#[Scheduled] MainTaskCleanup::purge() must be a non-static method.Ошибки конфигурации валят весь скан, поэтому увидите вы их сразу — в том числе на
php call schedule list.
Триггеры
Каданс задаётся ровно одним из трёх параметров. Два и больше — ошибка, ни одного — тоже.
| Параметр | Что означает |
|---|---|
fixedRate |
Запускать каждые N секунд, считая от начала прошлого запуска |
fixedDelay |
Ждать N секунд после окончания прошлого запуска |
cron |
Запускать по расписанию в терминах календаря |
fixedRate — раз в N секунд
Задаёт частоту. Задача, стартовавшая в 12:00:00 с fixedRate: 60, следующий раз
пойдёт в 12:01:00 — независимо от того, отработала она за секунду или за двадцать.
#[Scheduled(fixedRate: 60)]
public function pollExchangeRates(): void { /* ... */ }Годится для опроса с постоянной частотой: метрики, курсы, состояние внешнего сервиса.
fixedDelay — пауза между запусками
Задаёт промежуток покоя. Пауза отсчитывается от конца прошлого прогона, поэтому фактический период равен «работа + пауза».
#[Scheduled(fixedDelay: 10)]
public function drainQueue(): void { /* ... */ }Годится, когда важно дать системе передышку между тяжёлыми проходами — разбор очереди, обработка пачки файлов.
Разница между двумя видами хорошо видна на числах. Прогон занял 3 секунды, интервал в обоих случаях 5:
fixedRate 5: старт ─3с─ конец ──2с── старт период 5 с
fixedDelay 5: старт ─3с─ конец ────5с──── старт период 8 сcron — по календарю
Когда расписание выражается не частотой, а временем суток или днём недели.
#[Scheduled(cron: '0 3 * * *')] // каждый день в 03:00
public function nightlyReport(): void { /* ... */ }
#[Scheduled(cron: '30 6,18 * * 1-5')] // в 06:30 и 18:30 по будням
public function shiftSummary(): void { /* ... */ }Выражение состоит ровно из пяти полей:
минута час день месяца месяц день недели
0-59 0-23 1-31 1-12 0-7 (0 и 7 — воскресенье)| Приём | Пример | Значение |
|---|---|---|
| Любое значение | * |
Каждую минуту / час / день |
| Точное значение | 30 |
Ровно в 30 |
| Список | 6,18 |
В 6 и в 18 |
| Диапазон | 1-5 |
С понедельника по пятницу |
| Шаг | */5 |
Каждые пять |
Поддерживаются макросы: @yearly, @monthly, @weekly, @daily (он же
@midnight), @hourly.
Чего в выражении нет
Это не полный синтаксис из других систем. Проверено:
- Секунд нет — полей ровно пять.
* * * * * *даёт ошибку разбора. Для интервалов меньше минуты беритеfixedRate. - Имена не работают —
MON,JANотвергаются, поля только числовые. @rebootнет.- Точность — до минуты; время считается в часовом поясе сервера.
Отсрочка первого запуска
initialDelay откладывает первый запуск после старта приложения. Полезно,
чтобы тяжёлая задача не стартовала одновременно с прогревом сервиса.
#[Scheduled(fixedRate: 300, initialDelay: 60)]
public function warmCache(): void { /* ... */ }С cron параметр не сочетается — это ошибка конфигурации, а не игнорирование.
Несколько расписаний на один метод
Атрибут повторяем: если одну работу нужно делать по разным поводам, вешайте несколько.
#[Scheduled(cron: '0 9 * * 1-5')] // будни, утро
#[Scheduled(cron: '0 12 * * 6,0')] // выходные, полдень
public function sendDigest(): void { /* ... */ }Каждый атрибут становится самостоятельной записью в расписании.
Как задачи выполняются
Задача никогда не накладывается сама на себя. Пока прогон идёт, следующий не начнётся, каким бы ни было расписание. Отдельных блокировок писать не нужно.
Пропущенные срабатывания не копятся. Если прогон затянулся и «съел» несколько тиков, после освобождения задача запустится один раз, а не столько, сколько пропустила. Планировщик догоняет расписание, а не отрабатывает долг.
Задачи не мешают друг другу. Каждый запуск идёт в собственной корутине, так что медленная задача не сдвигает тайминг остальных.
Исключение внутри задачи не роняет планировщик. Оно попадает в лог уровнем
error, а расписание продолжает работать:
Scheduled MainTaskCleanup::purgeExpiredSessions threw: SQLSTATE[08006] ...Ошибка сборки объекта тоже не остановит расписание
Если контейнер не смог создать класс задачи, в лог уйдёт сообщение с подсказкой
проверить конструктор и #[Autowired], а задача продолжит «тикать» вхолостую —
метод просто не будет вызываться. Заметить это можно только по логу, поэтому после
добавления задачи стоит убедиться, что она отработала хотя бы раз.
Управление из консоли
php call schedule list # какие задачи найдены и с каким кадансом
php call schedule start # запустить в текущем терминале
php call schedule start -d # запустить в фоне
php call schedule stop # мягкая остановка (SIGTERM)
php call schedule status # состояние и число задач
php call schedule status -v # то же плюс расход CPU и памятиlist работает без запущенного приложения — это статический разбор проекта,
удобный для проверки после правки расписания.
Отдельный запуск через call schedule start нужен, когда планировщик разворачивают
самостоятельно, без веба. Если он объявлен в манифесте, php call run поднимет его
вместе с остальным приложением, и отдельная команда не требуется.
Из кода
Планировщик — это процесс, поэтому та же поверхность управления доступна и из приложения:
use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler;
Scheduler::dispatch(); // поднять в фоне, вернёт PID
Scheduler::status()?->activity; // работает ли прямо сейчас
Scheduler::stop(); // мягкая остановкаЕсли вы объявили собственный планировщик через #[EnableScheduler(MyScheduler::class)],
вызывайте эти методы на своём классе. Что возвращает status() и какие ещё
операции есть — в разделе
Управление из кода на странице процессов.
Один планировщик на хост — и ни одного на кластер
Повторный запуск на той же машине блокируется: вторая попытка получит
ProcessAlreadyRunningException. Но защиты между машинами нет. Если приложение
раскатано на три пода, планировщик поднимется в каждом, и ночной отчёт уйдёт трижды.
Координации между хостами фреймворк не предоставляет. Разворачивайте планировщик в одном экземпляре — отдельным безголовым развёртыванием — либо берите блокировку сами, например через общий Redis.
Примеры
Разгрузка очереди с передышкой
<?php
namespace Main\Task;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
class OutboxSender
{
#[Autowired] private OutboxRepository $outbox;
#[Autowired] private Mailer $mailer;
#[Scheduled(fixedDelay: 5, initialDelay: 10)]
public function send(): void
{
foreach ($this->outbox->takePending(limit: 50) as $letter) {
$this->mailer->send($letter);
$this->outbox->markSent($letter->id);
}
}
}fixedDelay здесь уместнее частоты: пока писем много, проходы идут один за другим
с паузой в пять секунд; когда очередь пуста, нагрузки нет.
Ночной регламент из нескольких шагов
<?php
namespace Main\Task;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
use Psr\Log\LoggerInterface;
class NightlyMaintenance
{
#[Autowired] private ReportService $reports;
#[Autowired] private ArchiveService $archive;
#[Autowired] private LoggerInterface $logger;
#[Scheduled(cron: '0 2 * * *')]
public function archiveOldOrders(): void
{
$moved = $this->archive->moveOlderThan('-1 year');
$this->logger->info('orders archived', ['count' => $moved]);
}
#[Scheduled(cron: '30 2 * * *')]
public function buildDailyReport(): void
{
$this->reports->buildFor(new \DateTimeImmutable('yesterday'));
}
#[Scheduled(cron: '0 3 * * 1')] // по понедельникам
public function buildWeeklyReport(): void
{
$this->reports->buildWeekly();
}
}Шаги разнесены по времени намеренно: планировщик не гарантирует порядок между разными задачами, поэтому зависимость «архив раньше отчёта» выражается интервалом, а не соседством в коде. Если порядок обязателен, объедините шаги в один метод.
Расписание не из атрибутов
Когда расписание должно храниться в базе и меняться без развёртывания, переопределите поиск задач в собственном планировщике:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Schedule\ScheduledTask;
use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler;
use Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger;
class DbScheduler extends Scheduler
{
/** @return ScheduledTask[] */
protected function discover(): array
{
$tasks = [];
foreach ($this->rules->all() as $rule) {
$tasks[] = new ScheduledTask(
className: $rule->class,
methodName: $rule->method,
trigger: new CronTrigger($rule->expression),
);
}
return $tasks;
}
}#[EnableScheduler(\Main\DbScheduler::class)]Сигнал SIGHUP заставляет планировщик перечитать расписание без остановки — для
такого сценария это основной способ применить изменения.
Вместе с #[Async]
Оба атрибута можно поставить на один метод, и это рабочее сочетание. Но оно меняет поведение планировщика сильнее, чем кажется, поэтому разберём его отдельно.
#[Scheduled(fixedRate: 60)]
#[Async]
public function syncCatalog(): void { /* ... */ }Что происходит
Планировщик берёт объект задачи из контейнера — и получает подменённый класс,
тот самый, который создаёт #[Async]. Значит, вызов метода уходит в исполнитель и
возвращает управление немедленно, ещё до того, как работа началась.
Для планировщика это выглядит как мгновенно отработавшая задача.
без #[Async]: запуск ──────── работа ──────── конец → следующий тик
с #[Async]: запуск ─ отправка ─ конец → следующий тик
└── работа идёт своим чередом ──────────►Что вы получаете
- Тик планировщика не ждёт задачу. Долгий прогон одной задачи больше не может задержать соседние — хотя планировщик и без того запускает их независимо.
- Отдельный пул на задачу. Указав исполнителя, вы ограничиваете именно эту
работу, не трогая остальные:
#[Async('pool.sync')].
Что вы теряете
Защиту от наложения. Это главное. Обычный #[Scheduled] гарантирует, что
задача не пойдёт второй раз, пока не завершилась первая. С #[Async] планировщик
считает прогон оконченным в момент отправки — и на следующем тике спокойно запустит
её снова, пока предыдущая ещё работает.
Задача с fixedRate: 60, выполняющаяся три минуты, накопит три одновременных
прогона. Дальше — сколько выдержит исполнитель.
Осмысленность fixedDelay. Пауза отсчитывается от «конца», а концом теперь
считается отправка. То есть fixedDelay перестаёт отличаться от fixedRate: обещание
«ждать N секунд после завершения работы» больше не выполняется.
Правила совместного использования
Только с ограниченным пулом
На общем исполнителе число одновременных прогонов не ограничено ничем: планировщик
будет отправлять новые, пока не кончится память воркера. Совмещая #[Scheduled] с
#[Async], всегда указывайте собственный пул с пределом.
#[Bean(name: 'pool.sync')]
public function syncPool(): ExecutorService
{
return Executors::newFixedExecutor(
concurrency: 2,
queue: 0, // очереди нет — лишнее отвергаем
onReject: RejectPolicy::DISCARD, // пропуск лучше, чем гора накопленного
);
}#[Scheduled(fixedRate: 60)]
#[Async('pool.sync')]
public function syncCatalog(): void { /* ... */ }Выбор политики отказа здесь и есть выбор поведения при перегрузке:
| Политика | Что будет, если прогон не успевает |
|---|---|
DISCARD |
Лишний запуск пропускается — ближе всего к поведению обычного #[Scheduled] |
CALLER_RUNS |
Планировщик выполнит задачу сам и подождёт — наложение снова исключено |
ABORT |
В лог уйдёт RejectedExecutionException — видно, что расписание не поспевает |
Когда это уместно
Стоит совмещать, когда задача идемпотентна и наложение не вредит, а важнее успевать за расписанием: рассылка по независимым получателям, обновление кеша, опрос нескольких источников.
Не стоит, когда наложение недопустимо — сведение отчёта, миграция, любая работа
с общим состоянием. Обычный #[Scheduled] уже даёт вам ровно ту гарантию, ради
которой иначе пришлось бы городить блокировку.
Проверьте, что `#[EnableAsync]` включён
Без него #[Async] не действует, и сочетание молча превращается в обычную
запланированную задачу — со всеми её гарантиями. Это безопасное поведение, но
отличается от того, что вы задумали, и никак себя не проявляет.
Дальше
- Состав приложения — где объявляется
#[EnableScheduler] - Процессы — планировщик устроен как процесс и наследует его возможности
- Демоны — когда работы больше, чем успевает один исполнитель
- Внедрение зависимостей — как задача получает сервисы
- Асинхронные вызовы — пулы исполнителей и политики отказа
- Логирование — куда уходят ошибки задач