0% прочитано

Idempotent Listener: чому обробник події має витримувати повторний запуск

Queued listener може виконатися повторно після retry, timeout, часткового збою або повторної події. Тому бізнес-операція за listener має бути безпечною при повторному запуску, а друге виконання не повинно створювати дублікати чи повторні side effects.

21 жовтня 2026 р. 11 хв читанняЛаравель

Після registration у мене є бізнес-операція, яка повинна прив’язати до нового користувача дані, створені до реєстрації.

На перший погляд listener простий: отримати event і викликати service.

Але щойно listener стає queued або потрапляє у flow з retry, виникає інше питання: що станеться, якщо той самий обробник виконається ще раз?

Для мене правильна відповідь така: другий запуск не повинен ламати стан, створювати дублікати або повторювати side effects, які вже відбулися.

Idempotent listener не означає, що event ніколи не буде доставлений двічі. Він означає, що повторна доставка не змінить результат після того, як потрібний стан уже досягнутий.

Повторний запуск listener - це нормальний сценарій, а не екзотична помилка

Успішна зміна може відбутися до того, як job вважається завершеною
event dispatched    |    vlistener starts    |    +-- database change succeeds    |    +-- process crashes before completion is acknowledged    |    vqueue retries listener

Retry може виникнути через timeout, exception після часткового виконання, перезапуск worker або повторну доставку повідомлення.

Окремо від queue той самий business event може бути dispatched повторно через bug, replay або інший trigger path.

Тому я не будую correctness на припущенні "цей listener виконається рівно один раз".

Retry і нова бізнес-операція - не одне й те саме

У матеріалі про різницю між retry і новою операцією я окремо розбирала цю межу.

Retry означає: система намагається завершити ту саму логічну операцію. Нова операція означає: користувач або система створили новий намір.

Для listener після registration повторний запуск зазвичай є retry тієї самої операції, тому результат має залишитися тим самим.

Небезпечний listener покладається на історію викликів

Другий запуск створить новий row і повторить side effect
final class ClaimPendingData{    public function handle(        UserRegistered $event,    ): void {        PendingClaim::query()->create([            'user_id' => $event->user->id,            'email' => $event->user->email,        ]);         $this->mailer->sendClaimedNotice(            $event->user,        );    }}

Такий код працює лише за умови exactly-once execution.

Перший retry уже може створити duplicate row, а email піде ще раз.

Проблема не в queue як такій. Проблема в тому, що сама бізнес-операція не має визначення "вже виконано".

Я намагаюся моделювати listener як перехід до потрібного стану

Повторний запуск бачить уже досягнутий стан
Before registration pending recorduser_id = NULLemail = user@example.test  After successful claim pending recorduser_id = 42email = user@example.test  Retry 

Такий спосіб мислення для мене простіший за "чи викликався listener раніше?".

Я не зберігаю історію виконання listener як головний критерій. Я дивлюся на canonical state і питаю, чи потрібна зміна ще існує.

Update з умовою naturally idempotent

Другий запуск просто оновить 0 rows
$updated = PendingConversation::query()    ->whereNull('user_id')    ->where(        'guest_email',        $user->email,    )    ->update([        'user_id' => $user->id,    ]);

У цьому прикладі умова whereNull('user_id') одночасно описує eligible state і робить повторне виконання безпечним.

Після першого успішного claim запис більше не відповідає умові. Retry не створює новий стан і не змінює вже прив’язаний запис.

Кількість реально змінених rows допомагає контролювати side effects

Side effect виникає тільки після реальної зміни стану
$claimedCount = $service->claimFor(    $user,); if ($claimedCount === 0) {    return;} $this->afterClaim->dispatch(    $user->id,    $claimedCount,);

Це важливо: idempotent database update ще не робить idempotent весь listener.

Якщо після no-op update безумовно відправляти notification, analytics event або email, retry усе одно залишиться помітним зовні.

Тому я прив’язую подальші side effects до факту реальної зміни.

Критичну зміну стану я тримаю в одній короткій transaction

State transition захищений від конкурентного claim
$claimedIds = DB::transaction(    function () use ($user): array {        $ids = PendingConversation::query()            ->whereNull('user_id')            ->where(                'guest_email',                $user->email,            )            ->lockForUpdate()            ->pluck('id');         if ($ids->isEmpty()) {            return [];        }         PendingConversation::query()

Idempotency і concurrency - різні проблеми, але вони часто зустрічаються разом.

Повторний sequential retry може бути безпечним, але два workers, які одночасно бачать той самий unclaimed row, уже створюють race condition.

Якщо operation цього потребує, я використовую transaction і locking саме навколо критичної зміни, а не навколо зовнішніх викликів.

Зовнішні side effects краще запускати після commit

Подальша подія не повинна випереджати commit
DB::afterCommit(    function () use (        $user,        $claimedIds,    ): void {        if ($claimedIds === []) {            return;        }         ClaimedDataChanged::dispatch(            $user->id,            $claimedIds,        );    });

Це не вирішує всі distributed consistency problems, але прибирає один небезпечний сценарій: side effect уже відбувся, а database transaction потім rollback.

При цьому follow-up handler теж бажано проектувати retry-safe, тому що after-commit dispatch не перетворює downstream систему на exactly-once delivery.

Якщо listener створює row, idempotency краще підкріпити constraint у базі

База не дозволяє двом retries створити один логічний результат двічі
Schema::table(    'registration_claims',    function (Blueprint $table): void {        $table            ->unique([                'user_id',                'claim_type',            ]);    });

firstOrCreate() або попередній exists() роблять intent зрозумілим, але при concurrency application-level check сам по собі може бути недостатнім.

Якщо duplicate state справді заборонений бізнес-моделлю, unique constraint дає останню лінію захисту.

Idempotency key має описувати логічну операцію, а не випадковий delivery

Якщо кожен retry отримує новий випадковий identifier і саме його використовувати як dedup key, система не впізнає повтор тієї самої операції.

Ключ повинен бути стабільним для одного business intent: наприклад, user + operation type або інший natural/explicit operation identifier.

Але якщо сама state transition naturally idempotent, окремий idempotency key може взагалі не знадобитися.

Правильний lifecycle event і idempotent listener вирішують різні проблеми

У статті про застаріле lifecycle-rule після зміни event проблема була в іншому: event уже змінився, але старий guard продовжував реалізовувати попередню семантику.

Тут наступний рівень. Навіть коли lifecycle уже правильний, handler повинен бути безпечним для повторного виконання.

Правильний event відповідає на питання "коли запускати?". Idempotency відповідає на питання "що буде, якщо запустити ще раз?".

Я завжди додаю тест на другий запуск

Тест перевіряє не тільки перший happy path, а й retry
public function test_listener_is_safe_when_run_twice(): void{    $user = User::factory()->create();     $pending = PendingConversation::factory()        ->create([            'user_id' => null,            'guest_email' => $user->email,        ]);     $event = new UserRegistered(        $user,    );     $this->listener->handle(        $event,

Окремо тестую, що retry не повторює side effect

Database state може бути правильним, а side effect все одно дублюватися
Event::fake([    ClaimedDataChanged::class,]); $this->listener->handle(    $event,); $this->listener->handle(    $event,); Event::assertDispatchedTimes(    ClaimedDataChanged::class,    1,);

Корисний тест - падіння після state change і повторний запуск

Саме такий сценарій показує справжню retry-safety
Attempt 1 claim database rows    |    vsuccess    |    vlater step throws  Attempt 2 listener runs again    |    v

Happy-path test не покаже проблеми з retry.

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

Сам listener я залишаю тонким, а idempotency тримаю в service

Retry-safety належить бізнес-операції, а не transport wrapper
final readonly class ClaimPendingData{    public function __construct(        private ClaimPendingDataService $service,    ) {    }     public function handle(        UserRegistered $event,    ): void {        $this->service->handle(            $event->user,        );    }}

Це важливо ще й тому, що та сама operation може пізніше викликатися не тільки з event listener.

Якщо idempotency живе в service, безпечним стає і manual retry, і command, і backfill, і інший trigger path.

Моя перевірка для queued listener

  • Що станеться, якщо handler виконається двічі?
  • Чи визначає operation стан "вже виконано" через canonical data?
  • Чи другий запуск стає no-op замість duplicate write?
  • Чи захищає database constraint від duplicate rows?
  • Чи безпечний concurrent запуск двох workers?
  • Чи side effects виконуються тільки після реальної state change?
  • Чи зовнішні effects не відбуваються до commit?
  • Чи є тест на два послідовні виклики?
  • Чи є тест на partial failure + retry?

Я не намагаюся гарантувати один виклик, я роблю повторний виклик безпечним

Для мене це найкорисніша зміна в підході до event listeners.

Замість того щоб будувати систему навколо припущення "цей event прийде один раз", я проектую state transition так, щоб повторний запуск не змінював уже правильний результат.

Retry-safe listener спирається на canonical state, робить no-op після досягнення потрібного стану, захищає duplicates на рівні бази й не повторює side effects без нової зміни.

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