Проблема: всередині транзакції відправляються події чи ставляться задачі в чергу, але транзакція ще не закомічена.
DB::transaction(function () use ($data) {
$order = Order::create($data);
OrderPlaced::dispatch($order); // слухачі виконуються ЗАРАЗ
SendInvoice::dispatch($order); // воркер може взяти задачу ДО коміту
$this->payments->charge($order); // а тут виняток - усе відкочено
});
Що піде не так:
- лист «Ваше замовлення оформлено» про замовлення, якого немає - транзакцію відкочено, а лист уже надіслано;
- воркер не знаходить запис: задача з черги стартує до коміту,
Order::find()повертаєnull-ModelNotFoundException; - зовнішні системи отримали дані, яких у базі немає (вебхук, синхронізація з CRM).
Інструменти Laravel:
ShouldDispatchAfterCommitна класі події - подія відправляється лише після успішного коміту (і зовсім не відправляється при відкоті);ShouldHandleEventsAfterCommitна слухачі - слухач виконується після коміту;afterCommit()на джобі чиafter_commit => trueу конфігурації з'єднання черги - задача потрапляє в чергу лише після коміту;DB::afterCommit(fn () => ...)- будь-яка дія після коміту поточної транзакції.
final class OrderPlaced implements ShouldDispatchAfterCommit { /* ... */ }
SendInvoice::dispatch($order)->afterCommit();
Чого це не вирішує: «після коміту» - не те саме, що «гарантовано». Якщо процес упаде між комітом і відправкою в чергу, подія втрачена: дані в базі є, а реакції - ні. Для критичних інтеграцій (оплати, облік) потрібен transactional outbox:
- в тій самій транзакції, що й зміна даних, записати повідомлення в таблицю
outbox; - окремий процес читає
outboxі відправляє повідомлення (в чергу, брокер, вебхук), позначаючи відправлені; - споживачі - ідемпотентні, бо повідомлення може прийти двічі.
Так зміна даних і факт «треба повідомити» атомарні - або обидва є, або жодного.
Інші правила:
- зовнішні виклики (HTTP, платежі) не робити всередині транзакції - транзакція тримає блокування весь час очікування відповіді, а відкотити зовнішню дію неможливо;
- транзакція - якомога коротша і лише навколо змін бази;
- тести:
RefreshDatabaseобгортає тест у транзакцію, тож «після коміту» в тестах поводиться особливо - Laravel це враховує, але сценарії з відкотом варто перевіряти явно.