From 5b858066052005f043962489ccacbf895949b597 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 16:31:43 +0200 Subject: [PATCH] fix(admin): catch racing ledger insert and re-fetch (idempotent under concurrency) Co-Authored-By: Claude Opus 4.8 --- .../Maintenance/MaintenanceNotifier.php | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/Services/Maintenance/MaintenanceNotifier.php b/app/Services/Maintenance/MaintenanceNotifier.php index 0a09315..a6cfa22 100644 --- a/app/Services/Maintenance/MaintenanceNotifier.php +++ b/app/Services/Maintenance/MaintenanceNotifier.php @@ -7,6 +7,7 @@ use App\Mail\MaintenanceCancelledMail; use App\Models\Customer; use App\Models\MaintenanceNotification; use App\Models\MaintenanceWindow; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; @@ -45,13 +46,17 @@ class MaintenanceNotifier /** Queue one mail per (window, customer, event), idempotently and retryably. */ public function deliver(MaintenanceWindow $window, Customer $customer, string $event, Mailable $mail): void { - // firstOrCreate is atomic on the unique (window, customer, event) index, - // so at most one caller creates the row — no duplicate on the first send. - $delivery = MaintenanceNotification::query()->firstOrCreate( - ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event], - ['email' => $customer->email], - ); - $wasNew = $delivery->wasRecentlyCreated; + $keys = ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event]; + + // firstOrCreate can still race two first-time inserts; the unique index + // makes one lose — catch it and re-fetch so the operation stays idempotent. + try { + $delivery = MaintenanceNotification::query()->firstOrCreate($keys, ['email' => $customer->email]); + $wasNew = $delivery->wasRecentlyCreated; + } catch (UniqueConstraintViolationException) { + $delivery = MaintenanceNotification::query()->where($keys)->firstOrFail(); + $wasNew = false; + } $priorUpdatedAt = $delivery->updated_at; // Claim under a row lock so two workers can't both pass a stale-retry