fix(admin): read-only send-time guard (never drop mail on retry); document exactly-once residual for real-mail outbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
e09965f918
commit
87a81f5f40
|
|
@ -49,20 +49,25 @@ class AppServiceProvider extends ServiceProvider
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Send-time guard for maintenance mail (X-CP-Notification carries the
|
// Send-time guard for maintenance mail (X-CP-Notification carries the
|
||||||
// ledger id). Uses a distinct in-flight claim (claimed_at) so exactly one
|
// ledger id). Deliberately READ-ONLY — it never marks the row sent or
|
||||||
// copy ships without ever pre-marking delivery:
|
// claimed, because returning false makes Laravel treat the send as a
|
||||||
// • already delivered (sent_at set) or window cancelled → suppress;
|
// successful cancellation: pre-claiming here would silently DROP a mail
|
||||||
// • otherwise atomically claim claimed_at (null or older than the TTL) —
|
// whose transport later fails and retries. It only suppresses a send that
|
||||||
// the winner sends; a concurrent duplicate loses the claim and aborts.
|
// is definitively pointless: the window was cancelled, or a sibling copy
|
||||||
// sent_at is set only on real delivery (MessageSent), so a transport
|
// already delivered (sent_at set). sent_at is recorded on real delivery
|
||||||
// failure leaves the claim to expire and the mail stays retryable.
|
// in MessageSent, so nothing is ever lost.
|
||||||
|
//
|
||||||
|
// Residual: two jobs whose MessageSending both fire before either's
|
||||||
|
// MessageSent (true simultaneous multi-worker send of a stale resend) can
|
||||||
|
// still both deliver. That cannot happen on the current single-worker
|
||||||
|
// log-mail setup; true exactly-once needs a transactional outbox + a
|
||||||
|
// dedicated delivery worker keyed on claimed_at, to be added with real mail.
|
||||||
Event::listen(MessageSending::class, function (MessageSending $event) {
|
Event::listen(MessageSending::class, function (MessageSending $event) {
|
||||||
$header = $event->message->getHeaders()->get('X-CP-Notification');
|
$header = $event->message->getHeaders()->get('X-CP-Notification');
|
||||||
if ($header === null) {
|
if ($header === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$id = (int) $header->getBodyAsString();
|
$notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString());
|
||||||
$notification = MaintenanceNotification::query()->with('window')->find($id);
|
|
||||||
if ($notification === null) {
|
if ($notification === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -73,13 +78,7 @@ class AppServiceProvider extends ServiceProvider
|
||||||
return false; // window cancelled meanwhile — do not deliver
|
return false; // window cancelled meanwhile — do not deliver
|
||||||
}
|
}
|
||||||
|
|
||||||
$claimed = MaintenanceNotification::query()
|
return null;
|
||||||
->whereKey($id)
|
|
||||||
->whereNull('sent_at')
|
|
||||||
->where(fn ($q) => $q->whereNull('claimed_at')->orWhere('claimed_at', '<=', now()->subMinutes(15)))
|
|
||||||
->update(['claimed_at' => now()]);
|
|
||||||
|
|
||||||
return $claimed === 0 ? false : null; // lost the claim → a sibling is delivering
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stamp a maintenance-notification ledger row as delivered only once the
|
// Stamp a maintenance-notification ledger row as delivered only once the
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue