presseportale/app/Console/Commands/AggregatePressReleaseViews.php
Kevin Adametz 48129e99a0 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

204 lines
8.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseEventType;
use App\Models\AppSetting;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Schreibt die gezählten Aufrufe aus dem Ereignis-Log in den Gesamtzähler
* `press_releases.hits`.
*
* Hintergrund: Früher lief pro Seitenaufruf ein `UPDATE press_releases SET
* hits = hits + 1` im Request. Auf der über 1 GB großen Tabelle blockiert
* dieser Schreibzugriff, sobald eine Schemaänderung läuft (FULLTEXT-Indizes
* erzwingen dort einen COPY-Rebuild) — die wartenden Requests haben dann
* PHP-FPM-Worker und MySQL-Verbindungen belegt, bis die ganze Seite stand.
* Der Request schreibt jetzt nur noch das Ereignis in die kleine Log-Tabelle;
* die Summierung passiert hier gebündelt.
*
* Fortschritt läuft über eine Wasserstandsmarke (höchste bereits verarbeitete
* Event-ID) statt über einen Zwischenspeicher: Das Log ist die dauerhafte
* Quelle, dadurch geht bei einem Neustart kein Aufruf verloren und ein
* doppelter Lauf zählt nichts doppelt. Zähler und Marke werden je Durchgang
* gemeinsam committet — sonst wäre nur der Durchgang selbst, nicht der
* einzelne Abbruch darin, wiederanlaufsicher.
*/
class AggregatePressReleaseViews extends Command
{
protected $signature = 'press-releases:aggregate-views
{--chunk=5000 : Ereignisse je Durchgang}
{--budget=480 : Zeitbudget in Sekunden; der Rest wartet auf den nächsten Lauf}';
protected $description = 'Überträgt gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits.';
/**
* Wasserstandsmarke: höchste bereits in `hits` verbuchte Event-ID.
*/
public const WatermarkKey = 'press_release_views.aggregated_through_id';
/**
* Cache-Lock über alle Einstiege (Scheduler UND manueller Aufruf): Zwei
* gleichzeitige Läufe lesen dieselbe Marke und verbuchen denselben Chunk
* doppelt (Audit F19). withoutOverlapping() schützt nur den Scheduler.
*/
public const LockKey = 'press-releases:aggregate-views';
/**
* Der Lock verfällt nach dieser Zeit. Das Zeitbudget (--budget) muss
* darunter liegen, sonst kann ein zweiter Lauf den Lock übernehmen,
* während der erste noch verbucht (Audit U13). Als letzte Schranke wird
* die Marke nur unter Zeilensperre und nur vom erwarteten Stand aus
* fortgeschrieben – ein fremder Stand bricht den Durchgang ab.
*/
public const LockSeconds = 600;
public function handle(): int
{
$lock = Cache::lock(self::LockKey, self::LockSeconds);
if (! $lock->get()) {
$this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.');
return self::SUCCESS;
}
try {
return $this->aggregate();
} finally {
$lock->release();
}
}
private function aggregate(): int
{
$chunk = max(100, (int) $this->option('chunk'));
$budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget')));
$startedAt = microtime(true);
// Ungecacht (Audit N21): Die Marke ist Betriebszustand, kein
// Admin-Schalter. Ein veralteter Cachewert ließe jeden Lauf an der
// Prüfung unter der Sperre scheitern — dauerhaft.
$watermark = AppSetting::getValueFresh(self::WatermarkKey);
// Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu
// lesen wäre fatal — `press_releases.hits` enthält den Bestand bereits,
// ein Lauf ab 0 würde jedes Alt-Ereignis ein zweites Mal aufaddieren.
// Das kann im Deploy-Fenster passieren, wenn der Scheduler den neuen
// Task startet, bevor die Migration gelaufen ist. Also setzen wir die
// Marke auf den Ist-Stand und zählen erst ab dem nächsten Aufruf.
if ($watermark === null) {
$latest = (int) PressReleaseEvent::query()->max('id');
AppSetting::setValue(self::WatermarkKey, $latest);
$this->info("Wasserstandsmarke erstmalig auf Event-ID {$latest} gesetzt — der Bestand steckt bereits in `hits`.");
return self::SUCCESS;
}
$watermark = (int) $watermark;
$totalEvents = 0;
$totalReleases = 0;
while (true) {
if (microtime(true) - $startedAt > $budget) {
$this->warn(sprintf('Zeitbudget von %d s erreicht – der Rest folgt im nächsten Lauf.', $budget));
break;
}
// Obergrenze des Durchgangs zuerst festlegen, damit gleichzeitig
// eintreffende Aufrufe die Summe nicht gegen die Marke verschieben.
// Bewusst über die IDs statt über max(): eine Aggregatfunktion
// ignoriert das limit und lieferte die Marke des GESAMTEN Rests.
$ids = PressReleaseEvent::query()
->where('id', '>', $watermark)
->orderBy('id')
->limit($chunk)
->pluck('id');
if ($ids->isEmpty()) {
break;
}
$upperBound = (int) $ids->last();
$counts = PressReleaseEvent::query()
->where('id', '>', $watermark)
->where('id', '<=', $upperBound)
->where('type', PressReleaseEventType::View->value)
->selectRaw('press_release_id, count(*) as views')
->groupBy('press_release_id')
->pluck('views', 'press_release_id');
// Zähler und Marke gehören in EINE Transaktion. Ohne sie wäre der
// Lauf nur zwischen den Durchgängen wiederanlaufsicher: Bricht der
// Prozess mitten im Chunk ab (OOM, Deploy, kill), sind ein Teil der
// Zähler schon erhöht, die Marke steht aber noch auf dem alten
// Stand — der nächste Lauf verbuchte genau diese Aufrufe ein
// zweites Mal.
try {
$this->bookChunk($counts, $watermark, $upperBound, $totalEvents, $totalReleases);
} catch (RuntimeException $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
}
$watermark = $upperBound;
}
$this->info("Aufrufe verbucht: {$totalEvents} auf {$totalReleases} Pressemitteilungen (Stand Event-ID {$watermark}).");
return self::SUCCESS;
}
/**
* Zähler und Marke in EINER Transaktion; siehe Kommentar im Aufrufer.
*
* @param Collection<int|string, int|string> $counts
*/
private function bookChunk(Collection $counts, int $watermark, int $upperBound, int &$totalEvents, int &$totalReleases): void
{
DB::transaction(function () use ($counts, $watermark, $upperBound, &$totalEvents, &$totalReleases): void {
// Marke unter Zeilensperre gegen den erwarteten Stand prüfen:
// hat ein anderer Lauf sie inzwischen verschoben (Lock
// verfallen), würde dieser Durchgang dieselben Ereignisse ein
// zweites Mal verbuchen (Audit U13).
$current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first();
if ($current === null || (int) $current->value !== $watermark) {
// Sollte je ein Cache-Leser die Marke veraltet halten: hier
// verwerfen, damit der nächste Lauf sauber neu beginnt.
AppSetting::forgetCached(self::WatermarkKey);
throw new RuntimeException(sprintf(
'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.',
$current === null ? 'null' : (string) $current->value,
$watermark,
));
}
foreach ($counts as $pressReleaseId => $views) {
PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey((int) $pressReleaseId)
->increment('hits', (int) $views);
$totalReleases++;
$totalEvents += (int) $views;
}
AppSetting::setValue(self::WatermarkKey, $upperBound);
});
}
}