fix(admin): atomically claim maintenance notification retries under a row lock
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
8bb93137a3
commit
72731112f4
|
|
@ -8,6 +8,7 @@ use App\Models\Customer;
|
||||||
use App\Models\MaintenanceNotification;
|
use App\Models\MaintenanceNotification;
|
||||||
use App\Models\MaintenanceWindow;
|
use App\Models\MaintenanceWindow;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,29 +45,42 @@ class MaintenanceNotifier
|
||||||
/** Queue one mail per (window, customer, event), idempotently and retryably. */
|
/** Queue one mail per (window, customer, event), idempotently and retryably. */
|
||||||
public function deliver(MaintenanceWindow $window, Customer $customer, string $event, Mailable $mail): void
|
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(
|
$delivery = MaintenanceNotification::query()->firstOrCreate(
|
||||||
['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event],
|
['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event],
|
||||||
['email' => $customer->email],
|
['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;
|
$wasNew = $delivery->wasRecentlyCreated;
|
||||||
$priorUpdatedAt = $delivery->updated_at;
|
$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 {
|
try {
|
||||||
Mail::to($customer->email)
|
Mail::to($customer->email)
|
||||||
->locale($customer->locale ?: config('app.locale'))
|
->locale($customer->locale ?: config('app.locale'))
|
||||||
->queue($mail);
|
->queue($mail);
|
||||||
} catch (\Throwable $e) {
|
} 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;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue