Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Планувальник

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

6 питань

Розклад описується в PHP, а не в crontab - тож він лежить у git разом із кодом.

Опис завдання у routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('vacancies:parse')->twiceDaily(6, 18);
Schedule::command('reports:send')->dailyAt('09:00');
Schedule::job(new CleanupTempFiles)->hourly();

Що потрібно на сервері - один запис у cron:

* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1

Саме так: щохвилини й один рядок на весь застосунок. Cron будить Laravel щохвилини, а той сам вирішує, чиє зараз час. Новий пункт розкладу не потребує змін у cron.

Перевірити, не чекаючи часу:

php artisan schedule:list      # що і коли має виконатися
php artisan schedule:run       # виконати те, чому час зараз
php artisan schedule:work      # тримати планувальник локально, без cron

Типові помилки новачка:

  • Прописати кожне завдання окремим рядком у crontab - тоді розклад із коду не працює зовсім.
  • Забути cd у шлях проєкту: команда не знайде застосунок.
  • Запускати від імені іншого користувача, ніж веб-сервер, - права на storage не збігаються, і завдання падає на записі логу.
  • Не дивитися на вивід: без emailOutputOnFailure() чи логування падіння завдання непомітне.

Докладніше в документації: Планування завдань

Планувальник дозволяє описати періодичні завдання прямо в коді. У Laravel 11+ розклад визначається в routes/console.php через фасад Schedule:

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:send')->dailyAt('08:00');
Schedule::job(new PruneLogs)->weekly();
Schedule::call(fn () => Cache::flush())->hourly();

На сервері потрібен лише один cron-запис, що щохвилини викликає планувальник:

* * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1

Корисні модифікатори: withoutOverlapping(), onOneServer(), runInBackground().

Докладніше в документації: Планувальник завдань

Розклад описують у routes/console.php ланцюжком методів:

Schedule::command('reports:send')
    ->weekdays()
    ->at('9:00')
    ->timezone('Europe/Kyiv');

Schedule::command('cache:prune-stale-tags')->hourly();
Schedule::job(new SyncRates)->everyFifteenMinutes()->between('8:00', '20:00');
Schedule::command('backup:run')->dailyAt('3:30')->environments(['production']);
Schedule::command('invoices:remind')->daily()->when(fn () => Setting::get('reminders_on'));

Групи методів:

  • частота: everyMinute(), everyFiveMinutes(), hourly(), daily(), weekly(), monthlyOn(1, '8:00'), cron('0 */6 * * *'); навіть everySecond();
  • дні: weekdays(), weekends(), mondays(), days([1, 3]);
  • час: between(), unlessBetween();
  • умови: when(), skip(), environments();
  • пояс: timezone() для завдання чи schedule_timezone у config/app.php для всіх.

Пастка з поясом: якщо час сервера UTC, а бізнес у Києві, dailyAt('9:00') без поясу спрацює о 11 чи 12 за київським. З переходом на літній час завдання в проміжку 3:00-4:00 можуть пропуститися чи виконатися двічі - критичні запускають поза ним.

php artisan schedule:list показує, коли кожне завдання виконається наступного разу.

Докладніше в документації: Варіанти частоти розкладу

Дві проблеми: наступний запуск того самого завдання стартує, поки попереднє ще працює, і довге завдання затримує інші, заплановані на ту саму хвилину.

Накладання - withoutOverlapping():

Schedule::command('import:products')
    ->everyFiveMinutes()
    ->withoutOverlapping(30);   // блокування на 30 хв на випадок аварії

Поки попередній запуск триває, новий пропускається. Блокування живе в кеші: якщо процес упав, воно спаде за вказаний час (за замовчуванням - 24 години, тож розумний ліміт варто задати).

Затримка інших - runInBackground():

Schedule::command('analytics:report')->daily()->runInBackground();

Завдання на той самий час виконуються послідовно; фонове не змушує наступні чекати. Працює для command() і exec().

Кращий шлях для важкого - щоб планувальник лише ставив роботу в чергу:

Schedule::job(new ImportProducts)->everyFiveMinutes();

Тоді планувальник звільняється миттєво, а повтори, таймаути й паралельність бере на себе черга (ShouldBeUnique проти дублів).

На кількох серверах до цього додається onOneServer() - зі спільним кешем.

Докладніше в документації: Запобігання накладанню завдань

На одному сервері планувальник працює очевидно. Щойно серверів стає два, зʼявляються дві різні проблеми, які плутають між собою.

1. Завдання виконується двічі. Cron стоїть на обох серверах, тож щохвилинний schedule:run запускається двічі - і звіт розсилається двічі. Лікується onOneServer():

Schedule::command('reports:send')
    ->daily()
    ->onOneServer();

Перший сервер бере атомарне блокування в кеші, другий пропускає запуск. Драйвером за замовчуванням має бути database, memcached, dynamodb або redis, і всі сервери мусять дивитися в один центральний кеш; з file у кожного вузла кеш свій, тож блокування нічого не гарантує.

2. Завдання накладається саме на себе. Імпорт триває сім хвилин, а запускається щоп'ять - і другий екземпляр стартує поверх першого. Це вже про час виконання, а не про кількість серверів:

Schedule::command('import:run')
    ->everyFiveMinutes()
    ->withoutOverlapping();

За замовчуванням блокування тримається 24 години; якщо процес упав без звільнення, наступний запуск чекатиме. Тому варто задавати межу: withoutOverlapping(10).

Разом, коли потрібні обидві гарантії:

Schedule::command('import:run')
    ->everyFiveMinutes()
    ->withoutOverlapping(10)
    ->onOneServer();

Ще дві дрібниці, які кусаються. Часовий пояс планувальника береться з конфігурації застосунку - ->timezone('Europe/Kyiv') задає його явно. І завдання, що не пише жодного логу, мовчки не виконується місяцями; emailOutputOnFailure() або ->onFailure() роблять збій помітним.

Докладніше в документації: Планування завдань

Найнебезпечніший збій планувальника - тиша: cron зламався після міграції сервера, і резервні копії не робилися місяць.

Хуки за результатом:

Schedule::command('backup:run')
    ->dailyAt('3:00')
    ->onFailure(fn () => Log::critical('Резервна копія не вдалася'))
    ->pingOnSuccess('https://hc.example.com/ping/backup');

onSuccess() і onFailure() дивляться на код виходу команди - тож вона має повертати ненульовий код при збої.

Пінг «я живий» (dead man's switch): зовнішній сервіс чекає пінг за розкладом і тривожить, якщо його немає. Це ловить і збій команди, і те, що планувальник узагалі не запускався. pingBefore(), pingOnSuccess(), pingOnFailure().

Вивід: sendOutputTo() / appendOutputTo() у файл, emailOutputOnFailure() - листом лише при збої.

Сам планувальник:

  • cron-рядок * * * * * php artisan schedule:run чи schedule:work під supervisor у контейнері;
  • schedule:list після деплою - що й коли виконається;
  • події ScheduledTaskFailed, ScheduledTaskFinished - для власного моніторингу;
  • schedule:pause на час обслуговування - і не забути schedule:continue.

Pulse показує повільні й невдалі завдання черги, тож завдання, які планувальник лише ставить у чергу, видно і там.

Докладніше в документації: Хуки завдань