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 d6e49c11c3
commit c82f279fef
20 changed files with 927 additions and 89 deletions

View file

@ -18,6 +18,7 @@ use App\Console\Commands\PurgePressReleaseEvents;
use App\Console\Commands\PurgeRedactionSnapshots;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Console\Commands\SweepOrphanPressReleaseOriginals;
use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Foundation\Inspiring;
@ -39,6 +40,12 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
]);
};
// Sperren von withoutOverlapping() (Audit K-09): Ohne Angabe hält Laravel
// sie 24 h. Stürzt ein Lauf ab (OOM, Deploy, Container-Neustart), würde der
// Task bis zu einen Tag still übersprungen. Jede Sperre endet deshalb kurz
// nach der erwarteten Höchstlaufzeit: häufige Tasks nach ihrem Intervall,
// nächtliche nach 30–180 Minuten je nach Datenmenge.
// ========================================
// Tägliche Bereinigungen
// ========================================
@ -48,7 +55,7 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
// beendet sich der Befehl sofort.
Schedule::command(FetchMailBounces::class)
->everyTenMinutes()
->withoutOverlapping()
->withoutOverlapping(10)
->runInBackground()
->onFailure($alertOnFailure('mail:fetch-bounces'));
@ -57,21 +64,21 @@ Schedule::command(FetchMailBounces::class)
// Befund darf nicht unbegrenzt als Domainsperre stehen bleiben.
Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
->dailyAt('04:20')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('mail:check-domains'));
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('magic-links:purge'));
// Meldeverfahren: Schwärzungs-Snapshots nach Löschfrist entfernen (täglich 03:10)
Schedule::command(PurgeRedactionSnapshots::class)
->dailyAt('03:10')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('snapshots:purge'));
@ -79,13 +86,19 @@ Schedule::command(PurgeRedactionSnapshots::class)
// Tombstone-Stand exportieren (03:30), damit er einen Restore überlebt.
Schedule::command(ArchivePurge::class)
->dailyAt('03:20')
->withoutOverlapping()
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('archive:purge'));
// Bewusst eigener Termin statt ->then() an archive:purge (OPT-6, K-09):
// Als Anhängsel liefe der Export nach einem gescheiterten Purge gar nicht
// (onSuccess) bzw. ohne eigenen Failure-Alarm (then). So bleibt der Export
// auch dann aktuell und meldet sich selbst. Die Sperren enden nach 2 h bzw.
// 1 h; ein Purge, der länger als 10 Minuten läuft, überschneidet sich mit dem
// Export — der Export liest nur und nimmt den nächsten Stand am Folgetag mit.
Schedule::command(ArchiveExportTombstones::class)
->dailyAt('03:30')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('archive:export-tombstones'));
@ -93,13 +106,13 @@ Schedule::command(ArchiveExportTombstones::class)
// nach 24 h Karenz entfernen (03:40) — Umsetzungsaudit 25.09.2026, N11.
Schedule::command(ArchiveRetryPending::class)
->dailyAt('03:35')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('archive:retry-pending'));
Schedule::command(ArchiveSweepOrphans::class)
->dailyAt('03:40')
->withoutOverlapping()
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('archive:sweep-orphans'));
@ -108,10 +121,21 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
->weekly()
->sundays()
->at('04:00')
->withoutOverlapping()
->withoutOverlapping(180)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-drafts'));
// Beweisoriginale ohne Bildzeile (Umsetzungsaudit 29.09.2026, B-11): Reste
// eines hart abgebrochenen Uploads. 48 h Karenz schützt laufende Uploads;
// während eines Restore-Abgleichs (Archiv-Sweep pausiert) löscht er nichts.
Schedule::command(SweepOrphanPressReleaseOriginals::class, ['--execute', '--hours=48'])
->weekly()
->sundays()
->at('04:45')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('press-release-images:sweep-orphan-originals'));
// ========================================
// Geplante PM-Veröffentlichung
// ========================================
@ -120,7 +144,7 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
// Läuft alle 5 Min — passt zum FormRule "scheduled_at min. 5 Min in Zukunft".
Schedule::command(PublishScheduledPressReleases::class)
->everyFiveMinutes()
->withoutOverlapping()
->withoutOverlapping(10)
->runInBackground()
->onFailure($alertOnFailure('press-releases:publish-scheduled'));
@ -133,17 +157,18 @@ Schedule::command(PublishScheduledPressReleases::class)
// darf keine Web-Worker blockieren (siehe AggregatePressReleaseViews).
Schedule::command(AggregatePressReleaseViews::class)
->everyMinute()
->withoutOverlapping()
->withoutOverlapping(15)
->runInBackground()
->onFailure($alertOnFailure('press-releases:aggregate-views'));
// Ereignis-Log auf den Aufbewahrungszeitraum begrenzen (täglich 03:20).
// Ereignis-Log auf den Aufbewahrungszeitraum begrenzen (täglich 03:50 —
// nicht mehr zeitgleich mit archive:purge um 03:20, Audit K-09).
// Die Tabelle nimmt jeden Seitenaufruf auf und wuchs als einzige Log-Tabelle
// des Systems unbegrenzt. Gelöscht wird nur unterhalb der Wasserstandsmarke —
// noch nicht in `hits` verbuchte Aufrufe bleiben unangetastet.
Schedule::command(PurgePressReleaseEvents::class)
->dailyAt('03:20')
->withoutOverlapping()
->dailyAt('03:50')
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-events'));
@ -151,7 +176,7 @@ Schedule::command(PurgePressReleaseEvents::class)
// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort.
Schedule::command(CheckIndexStatus::class)
->dailyAt('05:10')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('press-releases:check-index-status'));
@ -159,7 +184,7 @@ Schedule::command(CheckIndexStatus::class)
// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php.
Schedule::command(PurgeConversionEvents::class)
->dailyAt('03:25')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('conversion:purge-events'));
@ -172,7 +197,7 @@ Schedule::command(PurgeConversionEvents::class)
// abgerechnet wird.
Schedule::command(ExpireGrandfathering::class)
->dailyAt('04:15')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('billing:expire-grandfathering'));
@ -181,7 +206,7 @@ Schedule::command(ExpireGrandfathering::class)
// Stripe (STR-Kreis) und werden hier nicht angefasst.
Schedule::command(GenerateManualInvoices::class)
->dailyAt('04:30')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('billing:generate-manual-invoices'));
@ -192,7 +217,7 @@ Schedule::command(GenerateManualInvoices::class)
// Monatlicher Reset des verbrauchten PM-Kontingents (am 1. um 00:05).
Schedule::command(ResetMonthlyPressReleaseQuota::class)
->monthlyOn(1, '00:05')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('press-releases:reset-monthly-quota'));
@ -204,6 +229,6 @@ Schedule::command(ResetMonthlyPressReleaseQuota::class)
// nur aus dem Cache — ein API-Ausfall leert die Anzeige nicht sofort.
Schedule::command(RefreshMarketData::class)
->everyFifteenMinutes()
->withoutOverlapping()
->withoutOverlapping(15)
->runInBackground()
->onFailure($alertOnFailure('market:refresh'));