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
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Ein User darf pro Kampagne nur einmal im Protokoll stehen (Audit F05):
|
||||
* chunkById auf der nach created_at sortierten Empfängerquery hat ab der
|
||||
* zweiten Seite Empfänger doppelt eingefügt. Der Unique-Index macht den
|
||||
* Start idempotent (insertOrIgnore) und fängt jede weitere Doppelung ab.
|
||||
*
|
||||
* Bestehende Doppelungen werden vorher bereinigt — es bleibt jeweils die
|
||||
* älteste Zeile. Zeilen ohne User (gelöschte Accounts) sind nicht betroffen;
|
||||
* NULL zählt im Unique-Index nicht als Doppelung.
|
||||
*
|
||||
* Kleine Tabelle ohne FULLTEXT: der ALTER läuft sofort.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Abgeleitete Tabelle, damit MySQL das DELETE auf derselben Tabelle zulässt.
|
||||
DB::statement(<<<'SQL'
|
||||
DELETE FROM newsletter_campaign_recipients
|
||||
WHERE user_id IS NOT NULL
|
||||
AND id NOT IN (
|
||||
SELECT keep_id FROM (
|
||||
SELECT MIN(id) AS keep_id
|
||||
FROM newsletter_campaign_recipients
|
||||
WHERE user_id IS NOT NULL
|
||||
GROUP BY newsletter_campaign_id, user_id
|
||||
) AS keep
|
||||
)
|
||||
SQL);
|
||||
|
||||
Schema::table('newsletter_campaign_recipients', function (Blueprint $table): void {
|
||||
$table->unique(['newsletter_campaign_id', 'user_id'], 'newsletter_recipients_campaign_user_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('newsletter_campaign_recipients', function (Blueprint $table): void {
|
||||
$table->dropUnique('newsletter_recipients_campaign_user_unique');
|
||||
});
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue