0% прочитано

Laravel Delayed Queue Jobs: як не виконати stale job після перенесення публікації

У ICanUp delayed IndexNow job планується на майбутній published_at, але до моменту виконання дата, slug або public state можуть змінитися. На реальному Laravel-коді показую, як job повторно перевіряє актуальний state, відкидає stale scheduling і використовує поточні URLs замість snapshot з минулого.

8 вересня 2026 р. 9 хв читанняЛаравель

Під час роботи над scheduled IndexNow у ICanUp я зіткнулася з проблемою, якої немає у звичайного synchronous коду: між моментом scheduling і фактичним виконанням job може пройти багато часу.

За цей час Post можна відредагувати, перенести published_at, змінити slug або зробити так, що він взагалі перестане бути public.

Delayed job не повинен автоматично вважати, що умови, які були правильними під час dispatch, залишаються правильними під час виконання.

Коротке правило: delayed job - це намір виконати дію в майбутньому, а не гарантія, що початкові умови все ще актуальні. Перед side effect я повторно читаю current state і перевіряю, чи job досі має право виконуватися.
Laravel delayed queue job rechecking scheduled publication state before IndexNow submission
Scheduled job прокидається пізніше і спочатку перевіряє, чи publication state все ще відповідає тому, для якого його створили.

Реальний scheduled publication flow у ICanUp

Цей кейс з'явився в моїй реалізації IndexNow для автоматичної відправки URL у Bing.

Він також продовжує той самий transaction boundary, який я окремо розбирала у статті про Laravel DB::afterCommit() і Queue Jobs: спочатку database state має стати committed, а вже потім система планує зовнішню reaction.

Якщо Post уже має status published, але його published_at знаходиться в майбутньому, ICanUp створює delayed IndexNow job саме на цей час.

Сам job не публікує Post. Він лише реагує на момент, коли content має стати publicly visible.

PHP - Scheduling майбутньої IndexNow operation
public function schedulePost(Post $post): void{    if (        ! $post->isPublished()        || $post->published_at === null        || ! $post->published_at->isFuture()    ) {        return;    }     SubmitScheduledPostIndexNowJob::dispatch(        $post->getKey(),        $post->published_at->getTimestamp(),    )->delay($post->published_at);}

Scheduler передає в job лише Post ID і timestamp очікуваної publication time. Сам Post object і список URLs у queue не заморожуються.

Що саме job запам'ятовує під час scheduling

У constructor я передаю дві речі: postId і expectedPublishedAtTimestamp.

Timestamp тут працює як простий version marker для запланованої публікації.

Job пам'ятає не весь старий state, а лише той факт, для якої конкретної publication time він був створений.

PHP - Дані delayed job
public function __construct(    public int $postId,    public int $expectedPublishedAtTimestamp,) {}

Unique job залежить і від Post, і від publication time

PHP - Unique ID для scheduled job
public function uniqueId(): string{    return sprintf(        'post:%d:%d',        $this->postId,        $this->expectedPublishedAtTimestamp,    );}

Для Post 15, запланованого на timestamp 1000, unique key буде post:15:1000.

Якщо publication time перенести на timestamp 2000, новий job отримає вже post:15:2000.

Rescheduling створює нову версію майбутньої operation, а не намагається зробити старий job правильним.

StateUnique IDРезультат
Post 15, publication 18:00post:15:1000Перший scheduled job
Повторний dispatch для тих самих умовpost:15:1000Duplicate блокується uniqueness
Publication перенесенаpost:15:2000Новий scheduled job
Старий job прокидаєтьсяpost:15:1000Має перевірити, чи його timestamp ще актуальний

Старий job не видаляється - він сам стає stale

Спочатку я могла б думати про фізичне видалення попереднього delayed job з queue після кожного reschedule.

Але це створило б набагато складніший coordination problem.

У моїй реалізації старий job може залишитися в queue. Коли він прокинеться, він сам перевірить, чи ще відповідає поточному publication state.

PHP - Відхилення stale publication timestamp
if (    $post->published_at === null    || $post->published_at->getTimestamp()        !== $this->expectedPublishedAtTimestamp) {    return;}
Якщо publication time змінилася, старий delayed job завершується без IndexNow submission. Його не потрібно робити актуальним або вручну видаляти з queue.

Job повторно читає entity з database

PHP - Завантаження current Post state
$post = Post::query()->find($this->postId); if ($post === null) {    return;}

У queue я не передаю serialized snapshot усіх даних, потрібних для IndexNow submission.

Job отримує ID і під час виконання знову читає Post.

Це означає, що рішення приймається на основі актуального database state у момент виконання, а не копії з моменту scheduling.

Правильний timestamp ще не означає, що content public

PHP - Повторна перевірка public visibility
if (! $post->isPubliclyVisible()) {    return;}

Навіть якщо timestamp збігається, job не відправляє URLs, якщо Post більше не є publicly visible.

Ця додаткова перевірка важлива, тому що scheduled publication може змінитися не тільки через дату.

До моменту виконання content можна deactivate, archive або змінити іншим способом.

Кожна зовнішня operation має перевіряти саме ту business condition, від якої вона реально залежить.

URLs теж формуються заново

PHP - Формування актуальних IndexNow URLs
$urls = $indexNowUrlService->postTargets($post); if ($urls === []) {    return;} $client->submit($urls);

Ще одна важлива деталь - scheduler не передає в job список URLs.

Якщо після scheduling змінити slug, delayed job використає вже новий URL.

Для multilingual content це особливо корисно, тому що ICanUp може побудувати актуальні localized targets без використання старого snapshot.

До
php
SubmitScheduledPostIndexNowJob::dispatch(    $post->id,    $urls,)->delay($post->published_at);
Після
php
SubmitScheduledPostIndexNowJob::dispatch(    $post->id,    $post->published_at->getTimestamp(),)->delay($post->published_at);

Я волію передати мінімальний identity/version contract і побудувати current URLs пізніше, ніж заморожувати зовнішній payload на години вперед.

Повний guard sequence перед side effect

PHP - Повна перевірка scheduled Post перед submission
$post = Post::query()->find($this->postId); if ($post === null) {    return;} if (    $post->published_at === null    || $post->published_at->getTimestamp()        !== $this->expectedPublishedAtTimestamp) {    return;} if (! $post->isPubliclyVisible()) {    return;} $urls = $indexNowUrlService->postTargets($post); if ($urls === []) {    return;} $client->submit($urls);
  • Entity все ще існує.
  • Publication time все ще та сама.
  • Content зараз publicly visible.
  • Актуальні public URLs існують.
  • Лише після цього виконується зовнішній IndexNow submit.

Stale state і temporary failure - це різні проблеми

PHP - Retry і backoff configuration
#[Tries(3)]#[Backoff([60, 300, 900])]final class SubmitScheduledPostIndexNowJob    implements ShouldBeUnique, ShouldQueue{    // ...}

Якщо job stale, retry не допоможе. Його business condition уже неактуальна, тому правильна поведінка - просто завершитися.

Якщо ж current state правильний, але зовнішній service тимчасово падає, retry уже має сенс.

Не кожен failed-to-act сценарій є помилкою, яку потрібно retry.

ПричинаДія
Publication time зміниласяReturn
Entity видаленаReturn
Content більше не publicReturn
URLs відсутніReturn
Тимчасова помилка зовнішнього submitRetry
Повторний однаковий schedulingShouldBeUnique

Що я перевіряю в тестах

  • Future published Post отримує delayed job на точний published_at.
  • Draft або вже public content не створює scheduled job.
  • Однаковий Post і timestamp мають однаковий unique ID.
  • Нова publication time створює інший unique ID.
  • Старий job нічого не submit після reschedule.
  • Job використовує current slug і current URLs.
  • Content, який більше не public, не відправляється в IndexNow.

Цей pattern ширший за scheduled publication

IndexNow був конкретним use case, але сам pattern значно ширший.

Та сама проблема виникає з delayed emails, reminders, notifications, payment checks, scheduled exports або будь-якою operation, яка виконується через хвилини чи години після scheduling.

Чим довший проміжок між decision і execution, тим важливіше повторно перевіряти business condition.

Delayed job - це майбутній намір, а не збережена назавжди правда про систему.

Що я залишила собі на майбутнє

  • Не передавати в delayed job більше historical state, ніж справді потрібно.
  • Використовувати ID і version marker, якщо current entity можна перечитати пізніше.
  • Вважати reschedule новою версією майбутньої operation.
  • Не намагатися обов'язково видаляти stale jobs, якщо вони можуть безпечно self-invalidate.
  • Перед side effect повторно перевіряти business condition.
  • Формувати зовнішній payload максимально близько до моменту його використання.
  • Розділяти stale no-op і temporary failure, який справді потребує retry.

Висновок

Найважливішою частиною scheduled IndexNow flow у ICanUp для мене став не сам delay().

Справжня проблема починається після scheduling: система продовжує жити, а state може змінитися ще до виконання job.

Тому мій delayed job зберігає мінімальний contract, перечитує entity, порівнює expected publication timestamp, перевіряє public visibility і лише потім формує current URLs.

Job виконується не тому, що його колись поставили в queue. Він виконується тільки якщо причина, через яку його поставили в queue, все ще актуальна.