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
|
|
@ -4,23 +4,33 @@ namespace App\Enums;
|
|||
|
||||
/**
|
||||
* Versandstatus eines einzelnen Newsletter-Empfängers (Protokoll).
|
||||
*
|
||||
* Sending ist der Claim des Workers: Bleibt eine Zeile dauerhaft darin,
|
||||
* ist der Worker zwischen Mailserver-Übergabe und Protokollierung
|
||||
* abgestürzt — die Zustellung ist dann unklar; `newsletter:resume`
|
||||
* setzt solche Zeilen bewusst zurück.
|
||||
* SkippedOptOut greift, wenn sich der User zwischen Versandstart und
|
||||
* tatsächlichem Versand im Worker abgemeldet hat.
|
||||
* tatsächlichem Versand im Worker abgemeldet hat. Suppressed, wenn das
|
||||
* Versand-Gate die Adresse wegen der Sperrliste gestrichen hat.
|
||||
*/
|
||||
enum NewsletterRecipientStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Sending = 'sending';
|
||||
case Sent = 'sent';
|
||||
case Failed = 'failed';
|
||||
case SkippedOptOut = 'skipped_opt_out';
|
||||
case Suppressed = 'suppressed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Ausstehend',
|
||||
self::Sending => 'Wird versendet',
|
||||
self::Sent => 'Versendet',
|
||||
self::Failed => 'Fehlgeschlagen',
|
||||
self::SkippedOptOut => 'Übersprungen (abgemeldet)',
|
||||
self::Suppressed => 'Gesperrt (Sperrliste)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -31,9 +41,22 @@ enum NewsletterRecipientStatus: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'zinc',
|
||||
self::Sending => 'blue',
|
||||
self::Sent => 'green',
|
||||
self::Failed => 'red',
|
||||
self::SkippedOptOut => 'amber',
|
||||
self::Suppressed => 'orange',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Status, in denen der Versand noch aussteht oder läuft — solange eine
|
||||
* Kampagne solche Zeilen hat, ist sie nicht abgeschlossen.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function openValues(): array
|
||||
{
|
||||
return [self::Pending->value, self::Sending->value];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue