press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)

- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent c2e0b85242
commit f0eeef0144
20 changed files with 927 additions and 89 deletions

View file

@ -50,6 +50,14 @@ class ExpireGrandfathering extends Command
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
{
// E18 als Zusicherung statt nur als Kommentar (OPT-9): vor jeder
// Änderung, damit ein Lauf auf falscher Queue gar nichts anfasst.
if (($violation = $this->queueViolation()) !== null) {
$this->error($violation);
return self::FAILURE;
}
$candidates = $this->expiredQuery()->with('user')->get();
$pending = $this->pendingNotificationQuery()->with('user')->get();
@ -153,7 +161,8 @@ class ExpireGrandfathering extends Command
*
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
* ist damit Teil der Transaktion. Bei einem Wechsel auf Redis o. ä. bräuchte
* ist damit Teil der Transaktion. handle() sichert das zu (OPT-9,
* queueViolation()). Bei einem Wechsel auf Redis o. ä. bräuchte
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
* `notified_at` nach Zustellung).
*
@ -214,6 +223,36 @@ class ExpireGrandfathering extends Command
return [$notified, $failed];
}
/**
* Prüft die Annahme aus notifyPending() (E18): Der Mail-Job wird in
* derselben DB-Transaktion eingereiht wie der Marker. Das gilt nur für
* die Datenbank-Queue auf der Standard-Verbindung — oder für `sync`
* (Tests, lokale Läufe), wo der Versand innerhalb der Transaktion läuft
* und ein Fehler den Marker mit zurückrollt. Jede andere Queue würde den
* Job unabhängig vom Commit einreihen: dann lieber FAILURE (Alarm) als
* stille Doppel- oder Nicht-Zustellung.
*/
private function queueViolation(): ?string
{
$default = (string) config('queue.default');
if ($default === 'sync') {
return null;
}
if ($default !== 'database') {
return sprintf('E18 verletzt: QUEUE_CONNECTION ist „%s“, die Benachrichtigung ist nur mit der Datenbank-Queue idempotent. Nichts geändert.', $default);
}
$queueConnection = config('queue.connections.database.connection');
if ($queueConnection !== null && $queueConnection !== '' && $queueConnection !== config('database.default')) {
return sprintf('E18 verletzt: DB_QUEUE_CONNECTION „%s“ weicht von der Standard-Verbindung „%s“ ab, der Job-Insert läge außerhalb der Transaktion. Nichts geändert.', $queueConnection, config('database.default'));
}
return null;
}
/**
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
* Sperre und UPDATE identisch (U11).