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
0ab53bfa5e
commit
a7f991f01e
9 changed files with 752 additions and 63 deletions
|
|
@ -18,7 +18,7 @@ class MailCircuitCommand extends Command
|
|||
{
|
||||
if ($this->option('reset')) {
|
||||
$breaker->reset();
|
||||
$this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all');
|
||||
$this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all — laufende Newsletter-Kampagnen: php artisan newsletter:resume {kampagne} --failed');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
94
app/Console/Commands/ResumeNewsletterCampaign.php
Normal file
94
app/Console/Commands/ResumeNewsletterCampaign.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Services\Newsletter\CampaignSender;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Wiederanlauf einer laufenden Newsletter-Kampagne (Audit F14/F15): plant
|
||||
* alle ausstehenden Empfänger neu ein — nach abgebrochenem Start, nach
|
||||
* einer Pause des Circuit Breakers oder nach einem Worker-Absturz.
|
||||
*
|
||||
* `--failed` setzt fehlgeschlagene Empfänger zurück (z. B. nach behobener
|
||||
* Relay-Störung). Empfänger, die länger als `--stuck-minutes` in „sending“
|
||||
* hängen, gelten als Absturz-Reste: Ob die Mail dort schon raus war, ist
|
||||
* unklar — der Aufrufer entscheidet mit der Frist, ob er das Risiko eines
|
||||
* Doppelversands eingeht.
|
||||
*/
|
||||
class ResumeNewsletterCampaign extends Command
|
||||
{
|
||||
protected $signature = 'newsletter:resume
|
||||
{campaign : ID der Kampagne im Status „sending“}
|
||||
{--failed : Fehlgeschlagene Empfänger zurücksetzen und erneut einplanen}
|
||||
{--stuck-minutes=30 : Empfänger, die länger als X Minuten in „sending“ hängen, zurücksetzen (0 = keine)}';
|
||||
|
||||
protected $description = 'Plant die ausstehenden Empfänger einer laufenden Newsletter-Kampagne erneut ein.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$campaign = NewsletterCampaign::query()->find((int) $this->argument('campaign'));
|
||||
|
||||
if ($campaign === null) {
|
||||
$this->error('Kampagne nicht gefunden.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($campaign->status !== NewsletterCampaignStatus::Sending) {
|
||||
$this->error(sprintf('Kampagne #%d ist „%s“, nicht „%s“ — nichts zu tun.', $campaign->id, $campaign->status->label(), NewsletterCampaignStatus::Sending->label()));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$stuckMinutes = max(0, (int) $this->option('stuck-minutes'));
|
||||
$stuck = 0;
|
||||
|
||||
if ($stuckMinutes > 0) {
|
||||
$stuck = $campaign->recipients()
|
||||
->where('status', NewsletterRecipientStatus::Sending->value)
|
||||
->where('updated_at', '<', now()->subMinutes($stuckMinutes))
|
||||
->update([
|
||||
'status' => NewsletterRecipientStatus::Pending->value,
|
||||
'error' => null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$failed = 0;
|
||||
|
||||
if ($this->option('failed')) {
|
||||
$failed = DB::transaction(function () use ($campaign): int {
|
||||
$reset = $campaign->recipients()
|
||||
->where('status', NewsletterRecipientStatus::Failed->value)
|
||||
->update([
|
||||
'status' => NewsletterRecipientStatus::Pending->value,
|
||||
'error' => null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($reset > 0) {
|
||||
NewsletterCampaign::query()->whereKey($campaign->id)->decrement('failed_count', $reset);
|
||||
}
|
||||
|
||||
return $reset;
|
||||
});
|
||||
}
|
||||
|
||||
$dispatched = CampaignSender::dispatchPending($campaign);
|
||||
|
||||
$this->info(sprintf(
|
||||
'Kampagne #%d: %d Empfänger neu eingeplant (%d hängende zurückgesetzt, %d fehlgeschlagene zurückgesetzt).',
|
||||
$campaign->id,
|
||||
$dispatched,
|
||||
$stuck,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue