newsletter: jeden Empfänger genau einmal, atomarer Start, Wiederanlauf (Audit Block 2, Deploy B)
F05: chunkById hinter der created_at-Sortierung der Vorschau las ab der
zweiten Seite Empfänger doppelt und ließ die übrigen aus. Auf der Dev-
Kopie erreichte eine Kampagne an 59.705 Nutzer 500 Adressen, davon 480
doppelt. Die Sortierung wird vor dem ID-Chunking verworfen (E4: die
Versandreihenfolge hat keine fachliche Bedeutung), ein Unique-Index auf
(Kampagne, User) fängt jede weitere Doppelung ab; die Migration räumt
vorhandene Doppelungen vorher weg (auf der Dev-Kopie 470 Zeilen).
F14: Der Start übernimmt den Entwurf per bedingtem UPDATE draft→sending;
nur der Gewinner friert Empfänger ein (insertOrIgnore) und plant Jobs
aus dem Protokoll ein. Abbruch beim Einfrieren gibt den Entwurf frei.
F15: Der Versandjob beansprucht die Zeile pending→sending, schreibt
Empfängerstatus, Zähler und Kampagnenabschluss in einer Transaktion und
gibt den Claim bei Versandfehler zurück. Tries und Backoff am Job.
Zusatzbefund: Adressen auf der Sperrliste galten als „gesendet“, weil der
Rückgabewert von Mail::send nicht ausgewertet wurde — jetzt Status
„suppressed“ ohne Zähler.
Neu: php artisan newsletter:resume {kampagne} [--failed]
[--stuck-minutes=30] plant ausstehende Empfänger einer laufenden
Kampagne neu ein; der Circuit-Breaker-Hinweis nennt ihn statt
queue:retry, das Newsletter-Empfänger nicht nachholt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
1d76f5a733
commit
eeb1cde48d
9 changed files with 752 additions and 63 deletions
|
|
@ -8,8 +8,10 @@ use App\Enums\NewsletterRecipientStatus;
|
|||
use App\Mail\NewsletterCampaignMail;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Models\NewsletterCampaignRecipient;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
|
@ -20,20 +22,45 @@ use Illuminate\Support\Str;
|
|||
*
|
||||
* Der Opt-out wird hier — zum tatsächlichen Versandzeitpunkt — erneut
|
||||
* geprüft: Wer sich nach Versandstart abmeldet, wird übersprungen.
|
||||
*
|
||||
* Gegen Doppelversand und schiefe Zähler (Audit F15): Der Job übernimmt
|
||||
* die Zeile per bedingtem UPDATE pending → sending; verliert er den Claim,
|
||||
* ist ein anderer Worker dran. Empfängerstatus, Kampagnenzähler und
|
||||
* Kampagnenabschluss werden in einer Transaktion geschrieben. Schlägt der
|
||||
* Mailversand fehl, geht der Claim an „pending“ zurück und der Retry darf
|
||||
* neu zugreifen. Bleibt eine Zeile in „sending“, ist der Worker nach der
|
||||
* Mailserver-Übergabe abgestürzt: Zustellung unklar, kein automatischer
|
||||
* Neuversand — `newsletter:resume --stuck-minutes` entscheidet das bewusst.
|
||||
*/
|
||||
class SendNewsletterCampaignMail implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly int $recipientId) {}
|
||||
|
||||
/**
|
||||
* Wartezeit in Sekunden vor dem zweiten und dritten Versuch.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 120];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->claim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipient = NewsletterCampaignRecipient::query()
|
||||
->with(['campaign', 'user'])
|
||||
->find($this->recipientId);
|
||||
|
||||
if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) {
|
||||
if ($recipient === null || $recipient->campaign === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +79,22 @@ class SendNewsletterCampaignMail implements ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user));
|
||||
try {
|
||||
$sent = Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->release();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
// Ohne SentMessage hat das Versand-Gate die Mail gestrichen. Die
|
||||
// Sperrliste bestätigt das — der Rückgabewert allein trägt nicht,
|
||||
// die Mail-Attrappe der Tests liefert ebenfalls null.
|
||||
if ($sent === null && app(EmailSuppressionService::class)->isSuppressed($recipient->email)) {
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Suppressed, null, 'Adresse auf der Sperrliste.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Sent, 'sent_count');
|
||||
}
|
||||
|
|
@ -64,42 +106,91 @@ class SendNewsletterCampaignMail implements ShouldQueue
|
|||
{
|
||||
$recipient = NewsletterCampaignRecipient::query()->find($this->recipientId);
|
||||
|
||||
if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) {
|
||||
if ($recipient === null || ! in_array($recipient->status->value, NewsletterRecipientStatus::openValues(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $exception->getMessage());
|
||||
$error = $recipient->status === NewsletterRecipientStatus::Sending
|
||||
? 'Zustellung unklar (Fehler nach Übergabe an den Mailserver): '.$exception->getMessage()
|
||||
: $exception->getMessage();
|
||||
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bedingter Claim pending → sending. Genau ein Worker gewinnt.
|
||||
*/
|
||||
private function claim(): bool
|
||||
{
|
||||
return NewsletterCampaignRecipient::query()
|
||||
->whereKey($this->recipientId)
|
||||
->where('status', NewsletterRecipientStatus::Pending->value)
|
||||
->update([
|
||||
'status' => NewsletterRecipientStatus::Sending->value,
|
||||
'updated_at' => now(),
|
||||
]) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim zurückgeben, wenn der Versand nicht stattgefunden hat.
|
||||
*/
|
||||
private function release(): void
|
||||
{
|
||||
NewsletterCampaignRecipient::query()
|
||||
->whereKey($this->recipientId)
|
||||
->where('status', NewsletterRecipientStatus::Sending->value)
|
||||
->update([
|
||||
'status' => NewsletterRecipientStatus::Pending->value,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschluss in einer Transaktion: Empfängerstatus, Kampagnenzähler und
|
||||
* — wenn keine offene Zeile mehr da ist — Kampagnenabschluss. Die
|
||||
* Kampagnenzeile wird zuerst gesperrt, damit zwei gleichzeitig
|
||||
* endende Jobs den Abschluss nicht gegenseitig übersehen.
|
||||
*/
|
||||
private function conclude(
|
||||
NewsletterCampaignRecipient $recipient,
|
||||
NewsletterRecipientStatus $status,
|
||||
string $campaignCounter,
|
||||
?string $campaignCounter,
|
||||
?string $error = null,
|
||||
): void {
|
||||
$recipient->update([
|
||||
'status' => $status->value,
|
||||
'error' => $error !== null ? Str::limit($error, 480) : null,
|
||||
'sent_at' => $status === NewsletterRecipientStatus::Sent ? now() : null,
|
||||
]);
|
||||
DB::transaction(function () use ($recipient, $status, $campaignCounter, $error): void {
|
||||
$campaign = NewsletterCampaign::query()
|
||||
->whereKey($recipient->newsletter_campaign_id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$campaign = NewsletterCampaign::query()->find($recipient->newsletter_campaign_id);
|
||||
|
||||
if ($campaign === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign->increment($campaignCounter);
|
||||
|
||||
$hasPending = $campaign->recipients()
|
||||
->where('status', NewsletterRecipientStatus::Pending->value)
|
||||
->exists();
|
||||
|
||||
if (! $hasPending && $campaign->status === NewsletterCampaignStatus::Sending) {
|
||||
$campaign->update([
|
||||
'status' => NewsletterCampaignStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
$recipient->update([
|
||||
'status' => $status->value,
|
||||
'error' => $error !== null ? Str::limit($error, 480) : null,
|
||||
'sent_at' => $status === NewsletterRecipientStatus::Sent ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($campaign === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($campaignCounter !== null) {
|
||||
NewsletterCampaign::query()->whereKey($campaign->id)->increment($campaignCounter);
|
||||
}
|
||||
|
||||
$hasOpen = $campaign->recipients()
|
||||
->whereIn('status', NewsletterRecipientStatus::openValues())
|
||||
->exists();
|
||||
|
||||
if (! $hasOpen) {
|
||||
NewsletterCampaign::query()
|
||||
->whereKey($campaign->id)
|
||||
->where('status', NewsletterCampaignStatus::Sending->value)
|
||||
->update([
|
||||
'status' => NewsletterCampaignStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue