option('days') ?? config('press_releases.events.retention_days')); if ($days < 1) { $this->error('Aufbewahrungszeitraum muss mindestens einen Tag betragen.'); return self::FAILURE; } // Ungecacht (Audit N21): Dieser Leser darf die Marke nie veraltet in // den Cache zurückschreiben — siehe AggregatePressReleaseViews. $watermark = AppSetting::getValueFresh(AggregatePressReleaseViews::WatermarkKey); if ($watermark === null) { $this->error( 'Wasserstandsmarke des Aufruf-Zählers fehlt — es ist unbekannt, welche Ereignisse ' .'bereits in `hits` verbucht sind. Erst "php artisan press-releases:aggregate-views" laufen lassen.' ); return self::FAILURE; } $watermark = (int) $watermark; $cutoff = now()->subDays($days); $chunk = max(100, (int) $this->option('chunk')); // Grenz-ID statt Filter auf created_at (Umsetzungsaudit 29.09.2026, // OPT-2): press_release_events hat keinen Index auf created_at, jeder // Block-DELETE mit Datumsfilter liefe über die ganze Tabelle. Alles // unterhalb der ersten noch frischen ID ist abgelaufen — exakt, auch // wenn created_at und ID nicht streng gleich laufen. $expired = PressReleaseEvent::query() ->where('id', '<=', min($watermark, $this->lastExpiredId(PressReleaseEvent::query(), $cutoff))); if ($this->option('dry-run')) { $this->info(sprintf( '[Dry-Run] %d Ereignisse älter als %s und bereits verbucht (Stand Event-ID %d).', (clone $expired)->count(), $cutoff->format('d.m.Y'), $watermark, )); // Der echte Lauf räumt auch die beiden Abruf-Protokolle — der // Probelauf muss denselben Umfang nennen. $this->info(sprintf( '[Dry-Run] %d Einträge im Crawler-Abruf-Protokoll und %d im Feed-Abruf-Protokoll älter als %s.', PressReleaseCrawlerHit::query()->where('id', '<=', $this->lastExpiredId(PressReleaseCrawlerHit::query(), $cutoff))->count(), FeedFetch::query()->where('created_at', '<', $cutoff)->count(), $cutoff->format('d.m.Y'), )); return self::SUCCESS; } $deleted = 0; while (true) { $removed = (clone $expired)->limit($chunk)->delete(); if ($removed === 0) { break; } $deleted += $removed; } $this->info(sprintf( 'Ereignis-Log bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage).', $deleted, $cutoff->format('d.m.Y'), $days, )); // Crawler-Abruf-Protokoll (1c.0): gleiche Aufbewahrung, keine // Wasserstandsmarke nötig — es wird nirgends aufsummiert. $crawlerDeleted = 0; $crawlerUpperId = $this->lastExpiredId(PressReleaseCrawlerHit::query(), $cutoff); while (true) { $removed = PressReleaseCrawlerHit::query()->where('id', '<=', $crawlerUpperId)->limit($chunk)->delete(); if ($removed === 0) { break; } $crawlerDeleted += $removed; } $this->info(sprintf('Crawler-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $crawlerDeleted)); // Abruf-Protokoll der RSS-Feeds (3.1): gleiche Aufbewahrung, ebenfalls // nirgends aufsummiert. $feedDeleted = 0; while (true) { $removed = FeedFetch::query()->where('created_at', '<', $cutoff)->limit($chunk)->delete(); if ($removed === 0) { break; } $feedDeleted += $removed; } $this->info(sprintf('Feed-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $feedDeleted)); return self::SUCCESS; } /** * Höchste ID, unterhalb derer alles vor `$cutoff` liegt: eins unter der * ersten ID mit einem Zeitpunkt ab `$cutoff`. Die Suche läuft den * Primärschlüssel aufsteigend ab und liest damit nur die abgelaufenen * Zeilen (im Tagesbetrieb ein Tag), nicht die ganze Tabelle. * * @param Builder $query */ private function lastExpiredId(Builder $query, Carbon $cutoff): int { $firstFresh = (clone $query)->where('created_at', '>=', $cutoff)->orderBy('id')->value('id'); if ($firstFresh === null) { return (int) ((clone $query)->max('id') ?? 0); } return (int) $firstFresh - 1; } }