Під час роботи над scheduled IndexNow у ICanUp я зіткнулася з проблемою, якої немає у звичайного synchronous коду: між моментом scheduling і фактичним виконанням job може пройти багато часу.
За цей час Post можна відредагувати, перенести published_at, змінити slug або зробити так, що він взагалі перестане бути public.
Delayed job не повинен автоматично вважати, що умови, які були правильними під час dispatch, залишаються правильними під час виконання.

Реальний 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.
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 він був створений.
public function __construct( public int $postId, public int $expectedPublishedAtTimestamp,) {}Unique job залежить і від Post, і від publication time
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 правильним.
| State | Unique ID | Результат |
|---|---|---|
| Post 15, publication 18:00 | post:15:1000 | Перший scheduled job |
| Повторний dispatch для тих самих умов | post:15:1000 | Duplicate блокується uniqueness |
| Publication перенесена | post:15:2000 | Новий scheduled job |
| Старий job прокидається | post:15:1000 | Має перевірити, чи його timestamp ще актуальний |
Старий job не видаляється - він сам стає stale
Спочатку я могла б думати про фізичне видалення попереднього delayed job з queue після кожного reschedule.
Але це створило б набагато складніший coordination problem.
У моїй реалізації старий job може залишитися в queue. Коли він прокинеться, він сам перевірить, чи ще відповідає поточному publication state.
if ( $post->published_at === null || $post->published_at->getTimestamp() !== $this->expectedPublishedAtTimestamp) { return;}Job повторно читає entity з database
$post = Post::query()->find($this->postId); if ($post === null) { return;}У queue я не передаю serialized snapshot усіх даних, потрібних для IndexNow submission.
Job отримує ID і під час виконання знову читає Post.
Це означає, що рішення приймається на основі актуального database state у момент виконання, а не копії з моменту scheduling.
Правильний timestamp ще не означає, що content public
if (! $post->isPubliclyVisible()) { return;}Навіть якщо timestamp збігається, job не відправляє URLs, якщо Post більше не є publicly visible.
Ця додаткова перевірка важлива, тому що scheduled publication може змінитися не тільки через дату.
До моменту виконання content можна deactivate, archive або змінити іншим способом.
Кожна зовнішня operation має перевіряти саме ту business condition, від якої вона реально залежить.
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.
SubmitScheduledPostIndexNowJob::dispatch( $post->id, $urls,)->delay($post->published_at);SubmitScheduledPostIndexNowJob::dispatch( $post->id, $post->published_at->getTimestamp(),)->delay($post->published_at);Я волію передати мінімальний identity/version contract і побудувати current URLs пізніше, ніж заморожувати зовнішній payload на години вперед.
Повний guard sequence перед side effect
$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 - це різні проблеми
#[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 більше не public | Return |
| URLs відсутні | Return |
| Тимчасова помилка зовнішнього submit | Retry |
| Повторний однаковий scheduling | ShouldBeUnique |
Що я перевіряю в тестах
- 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, все ще актуальна.



