У системі нотифікацій легко зробити listener, який виглядає ідемпотентним, але все одно створює дублікати.
Подія може бути доставлена повторно: scheduler запускається ще раз, queue job отримує retry, worker падає після часткового виконання або два worker одночасно бачать одну й ту саму бізнес-подію.
Якщо кожен запуск безумовно створює новий notification record, користувач отримає дві однакові нотифікації, хоча бізнес-подія була лише одна.
Ідемпотентність listener недостатня
Listener може перевіряти стан моделі, permission або timestamp і все одно не захищати саме створення notification.
Два процеси можуть одночасно побачити, що один і той самий Post уже опублікований і автор має право отримати повідомлення. Обидва проходять eligibility check. Якщо кожен після цього виконує Notification::create(), маємо дубль.
Тому перевірка бізнес-умов і дедуплікація доставки мають бути окремими кроками.
Стабільна ідентичність бізнес-події
Для дедуплікації недостатньо знати тип notification. Потрібно однозначно визначити конкретну бізнес-подію.
Для першої фактичної публікації запланованого допису event identity може бути прив’язана до конкретного Post:
$eventKey = sprintf( 'scheduled-post-published:%d', $post->id,);Повторний scheduler run або retry для того самого Post повинні отримати той самий key. Інший Post того самого автора матиме інший key і повинен створити іншу notification.
Чому once per user тут неправильна межа
Один автор може опублікувати багато дописів. Якщо дедуплікувати лише за користувачем і template, другий реальний Post уже не створить потрібне повідомлення.
Межа має описувати конкретну бізнес-подію: цей Post, ця перша scheduled publication, цей отримувач і, якщо доставка відстежується окремо, конкретний channel.
exists перед insert не захищає від concurrency
if ( ! NotificationDispatch::query() ->where('event_key', $eventKey) ->exists()) { NotificationDispatch::query()->create([ 'event_key' => $eventKey, ]);}Такий код має вікно гонитви. Два worker можуть одночасно побачити exists() === false і обидва перейти до insert.
Гарантія повинна спиратися на atomic reservation або database uniqueness, а не лише на попередній SELECT.
Atomic reservation перед створенням notification
Практичний pattern - мати delivery ledger і спочатку атомарно зарезервувати event identity.
Database constraint має відповідати реальній idempotency boundary. Наприклад, у системі з окремим відстеженням каналів це можуть бути event key, отримувач і channel.
$inserted = DB::table( 'notification_dispatches')->insertOrIgnore([ 'event_key' => $eventKey, 'notifiable_type' => $user->getMorphClass(), 'notifiable_id' => $user->getKey(), 'channel' => 'database', 'created_at' => now(), 'updated_at' => now(),]); if ($inserted === 0) { return DeliveryResult::alreadyReserved();}Reservation і delivered - різні стани
Після reservation процес ще може впасти. Наприклад, database notification вже створена, а broadcast або mail channel тимчасово недоступний.
Тому ledger краще зберігає явний outcome: reserved, delivered, failed або skipped. Retry може завершити незакінчену доставку, не створюючи другу database notification.
Database notification перед broadcast
Realtime не повинен бути єдиним місцем, де існує повідомлення. Спочатку створюється durable database notification, а broadcast лише швидко повідомляє клієнту про вже існуючий серверний стан.
Тоді повторний broadcast не створює другу notification, а клієнт після reconnect може звіритися із сервером.
Retry повторює спробу, а не бізнес-подію
Queue retry означає "спробуй завершити роботу ще раз". Він не означає, що бізнес-подія сталася вдруге.
Зі стабільним event key повторна спроба знаходить ту саму reservation і або завершує пропущений channel, або повертає already delivered.
Scheduler потребує тієї самої гарантії
Scheduler часто працює як reconciler: регулярно знаходить записи, які тепер відповідають умовам. Один Post може бути видимим у кількох послідовних запусках.
Тому дедуплікація повинна бути прив’язана до event identity Post, а не до конкретного запуску cron.
Eligibility потрібно перевіряти повторно
Між scheduler discovery і виконанням queue job стан може змінитися. Post можуть видалити, перенести publication time або відкликати permission отримувача.
Delayed job має повторно перевірити eligibility і fail closed, якщо умови вже не виконуються.
Dispatch після commit
Якщо notification job відправити до commit транзакції, worker може побачити ще незафіксований стан або стан, який потім буде rolled back.
Для notification, що залежить від збереженої бізнес-події, dispatch після commit робить межу чіткішою.
DB::transaction( function () use ($post): void { $post->save(); DB::afterCommit( function () use ($post): void { DispatchNotification::dispatch( $post->id, ); }, ); },);Тести повинні спеціально намагатися створити дубль
Для idempotency недостатньо тесту "notification created". Потрібні сценарії:
- та сама подія оброблена двічі - одна notification;
- scheduler запускається повторно - одна notification;
- два різні Post одного автора - дві notifications;
- два конкурентні worker - одна reservation;
- retry після transient failure - без другої database notification;
- permission відкликано перед delivery - 0 notifications;
- повторний broadcast - без дубля в database channel.
public function test_same_event_is_delivered_once(): void{ $event = $this->scheduledPublicationEvent(); $this->dispatcher->handle($event); $this->dispatcher->handle($event); $this->assertDatabaseCount( 'notifications', 1, );}Не приховуйте duplicate як звичайний success
Корисно розрізняти результати delivered, already_delivered, skipped і failed.
Тоді під час QA видно, що другий запуск був безпечно дедуплікований, а не просто випадково нічого не зробив.
Повторний запуск технічного процесу не означає повторну бізнес-подію. Notification workflow повинен уміти відрізняти ці два випадки.
Підсумок
Надійна notification idempotency починається не з listener, а з правильно визначеної бізнес-події.
Стабільний event key, atomic reservation у ledger, database uniqueness, повторна перевірка eligibility і чіткий delivery outcome роблять scheduler retry та concurrent execution безпечними.
Тоді одна бізнес-подія створює одну notification, навіть якщо технічний процес виконується кілька разів.



