From 72731112f420b054d49f567f375a06217f364a4c Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 16:30:17 +0200 Subject: [PATCH] fix(admin): atomically claim maintenance notification retries under a row lock Co-Authored-By: Claude Opus 4.8 --- .../Maintenance/MaintenanceNotifier.php | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/app/Services/Maintenance/MaintenanceNotifier.php b/app/Services/Maintenance/MaintenanceNotifier.php index c171cfe..0a09315 100644 --- a/app/Services/Maintenance/MaintenanceNotifier.php +++ b/app/Services/Maintenance/MaintenanceNotifier.php @@ -8,6 +8,7 @@ use App\Models\Customer; use App\Models\MaintenanceNotification; use App\Models\MaintenanceWindow; use Illuminate\Mail\Mailable; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; /** @@ -44,29 +45,42 @@ 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], ); - - if ($delivery->sent_at !== null) { - return; // already delivered — final - } - if (! $delivery->wasRecentlyCreated && $delivery->updated_at->gt(now()->subMinutes(self::RETRY_AFTER_MIN))) { - return; // still pending/in-flight — don't duplicate - } - $wasNew = $delivery->wasRecentlyCreated; $priorUpdatedAt = $delivery->updated_at; - $delivery->forceFill(['updated_at' => now()])->save(); - $mail->notificationId = $delivery->id; + // Claim under a row lock so two workers can't both pass a stale-retry + // check and double-send. The winner stamps updated_at; the loser re-reads + // a fresh updated_at and backs off. + $claimed = DB::transaction(function () use ($delivery, $wasNew) { + $row = MaintenanceNotification::query()->whereKey($delivery->id)->lockForUpdate()->first(); + if ($row === null || $row->sent_at !== null) { + return false; // already delivered — final + } + if (! $wasNew && $row->updated_at->gt(now()->subMinutes(self::RETRY_AFTER_MIN))) { + return false; // still pending/in-flight — don't duplicate + } + $row->forceFill(['updated_at' => now()])->save(); + + return true; + }); + if (! $claimed) { + return; + } + + $mail->notificationId = $delivery->id; try { Mail::to($customer->email) ->locale($customer->locale ?: config('app.locale')) ->queue($mail); } catch (\Throwable $e) { - $wasNew ? $delivery->delete() : $delivery->forceFill(['updated_at' => $priorUpdatedAt])->save(); + // Release the claim so a resend retries immediately. + $wasNew ? $delivery->delete() : MaintenanceNotification::query()->whereKey($delivery->id)->update(['updated_at' => $priorUpdatedAt]); throw $e; }