diff --git a/.env.example b/.env.example index 98aa0fb7..245563c9 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,10 @@ DB_CONNECTION=sqlite # Migrationen auf großen Tabellen ggf. mit DB_LOCK_WAIT_TIMEOUT=300 starten. DB_LOCK_WAIT_TIMEOUT=10 +# Aufbewahrung des PM-Ereignis-Logs in Tagen (siehe config/press_releases.php). +# Bestimmt zugleich die Tiefe der Verlaufsanzeige. 395 = 13 Monate. +PRESS_RELEASE_EVENTS_RETENTION_DAYS=395 + # --------------------------------------------------------------------------- # Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich). # Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live. diff --git a/app/Console/Commands/AggregatePressReleaseViews.php b/app/Console/Commands/AggregatePressReleaseViews.php new file mode 100644 index 00000000..3390899b --- /dev/null +++ b/app/Console/Commands/AggregatePressReleaseViews.php @@ -0,0 +1,120 @@ +option('chunk')); + $watermark = AppSetting::getValue(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) { + // 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. + DB::transaction(function () use ($counts, $upperBound, &$totalEvents, &$totalReleases): void { + 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); + }); + + $watermark = $upperBound; + } + + $this->info("Aufrufe verbucht: {$totalEvents} auf {$totalReleases} Pressemitteilungen (Stand Event-ID {$watermark})."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PurgePressReleaseEvents.php b/app/Console/Commands/PurgePressReleaseEvents.php new file mode 100644 index 00000000..e5774cca --- /dev/null +++ b/app/Console/Commands/PurgePressReleaseEvents.php @@ -0,0 +1,99 @@ +option('days') ?? config('press_releases.events.retention_days')); + + if ($days < 1) { + $this->error('Aufbewahrungszeitraum muss mindestens einen Tag betragen.'); + + return self::FAILURE; + } + + $watermark = AppSetting::getValue(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')); + + $expired = PressReleaseEvent::query() + ->where('id', '<=', $watermark) + ->where('created_at', '<', $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, + )); + + 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, + )); + + return self::SUCCESS; + } +} diff --git a/app/Models/AppSetting.php b/app/Models/AppSetting.php index af45208f..0cf4ccad 100644 --- a/app/Models/AppSetting.php +++ b/app/Models/AppSetting.php @@ -43,6 +43,17 @@ class AppSetting extends Model public static function setValue(string $key, mixed $value): void { static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + static::forgetCached($key); + } + + /** + * Verwirft den zwischengespeicherten Wert. Nötig überall dort, wo an + * `app_settings` vorbei geschrieben wird — insbesondere in Migrationen, + * die den Query Builder benutzen: `getValue()` cacht dauerhaft + * (`rememberForever`), ein Direktschreiber bliebe sonst unsichtbar. + */ + public static function forgetCached(string $key): void + { Cache::forget("app-setting.{$key}"); } } diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 9be0d8ec..3df25138 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -356,15 +356,24 @@ class PressRelease extends Model */ public static function recordDeferredView(int $pressReleaseId): void { - static::query() - ->withoutGlobalScope(PortalScope::class) - ->whereKey($pressReleaseId) - ->increment('hits'); - - PressReleaseEvent::create([ - 'press_release_id' => $pressReleaseId, - 'type' => PressReleaseEventType::View, - ]); + // Bewusst NUR das Ereignis-Log (kleine Append-Tabelle). Der frühere + // `increment('hits')` traf die über 1 GB große press_releases-Tabelle + // und blockierte dort, sobald eine Schemaänderung lief — die + // wartenden Requests belegten PHP-FPM-Worker und DB-Verbindungen, bis + // die gesamte Seite stand. Die Summierung übernimmt jetzt gebündelt + // der Scheduler (AggregatePressReleaseViews). + // + // Der Aufruf-Zähler ist eine Nebensache: Er darf eine Seite niemals + // langsamer oder kaputt machen, deshalb wird jeder Fehler geschluckt + // und nur gemeldet. + try { + PressReleaseEvent::create([ + 'press_release_id' => $pressReleaseId, + 'type' => PressReleaseEventType::View, + ]); + } catch (\Throwable $e) { + report($e); + } } /** diff --git a/config/press_releases.php b/config/press_releases.php new file mode 100644 index 00000000..6cbd5e98 --- /dev/null +++ b/config/press_releases.php @@ -0,0 +1,28 @@ + [ + 'retention_days' => (int) env('PRESS_RELEASE_EVENTS_RETENTION_DAYS', 395), + ], + +]; diff --git a/database/migrations/2026_08_25_120000_seed_press_release_view_watermark.php b/database/migrations/2026_08_25_120000_seed_press_release_view_watermark.php new file mode 100644 index 00000000..c74876d2 --- /dev/null +++ b/database/migrations/2026_08_25_120000_seed_press_release_view_watermark.php @@ -0,0 +1,50 @@ +max('id'); + + DB::table('app_settings')->updateOrInsert( + ['key' => AggregatePressReleaseViews::WatermarkKey], + [ + 'value' => json_encode($latestEventId), + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + + // Hier wird am Model vorbei geschrieben, `AppSetting::getValue()` cacht + // aber dauerhaft. Lief der Scheduler-Task im Deploy-Fenster schon + // einmal, hielte der Cache die alte, niedrigere Marke — der nächste + // Lauf zählte die Ereignisse dazwischen ein zweites Mal auf `hits`. + AppSetting::forgetCached(AggregatePressReleaseViews::WatermarkKey); + } + + public function down(): void + { + DB::table('app_settings') + ->where('key', AggregatePressReleaseViews::WatermarkKey) + ->delete(); + + AppSetting::forgetCached(AggregatePressReleaseViews::WatermarkKey); + } +}; diff --git a/routes/console.php b/routes/console.php index 4591022d..853552da 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,9 +1,11 @@ runInBackground() ->onFailure($alertOnFailure('press-releases:publish-scheduled')); +// ======================================== +// Aufruf-Zähler +// ======================================== + +// Gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits verbuchen. +// Bewusst außerhalb des Requests: der Schreibzugriff auf die große Tabelle +// darf keine Web-Worker blockieren (siehe AggregatePressReleaseViews). +Schedule::command(AggregatePressReleaseViews::class) + ->everyMinute() + ->withoutOverlapping() + ->runInBackground() + ->onFailure($alertOnFailure('press-releases:aggregate-views')); + +// Ereignis-Log auf den Aufbewahrungszeitraum begrenzen (täglich 03:20). +// 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() + ->runInBackground() + ->onFailure($alertOnFailure('press-releases:purge-events')); + // ======================================== // Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen // ======================================== diff --git a/tests/Feature/PurgePressReleaseEventsTest.php b/tests/Feature/PurgePressReleaseEventsTest.php new file mode 100644 index 00000000..a6c59cca --- /dev/null +++ b/tests/Feature/PurgePressReleaseEventsTest.php @@ -0,0 +1,98 @@ + $release->getKey(), + 'type' => PressReleaseEventType::View, + ]); + + $event->forceFill(['created_at' => $createdAt])->save(); + + return $event; +} + +test('the purge drops aggregated events past the retention window', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + + $old = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); + $recent = purgeTestEvent($release, now()->subDays(10)->toDateTimeString()); + + AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $recent->id); + + $this->artisan('press-releases:purge-events')->assertSuccessful(); + + expect(PressReleaseEvent::query()->find($old->id))->toBeNull() + ->and(PressReleaseEvent::query()->find($recent->id))->not->toBeNull(); +}); + +test('the purge never touches events above the watermark', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + + $aggregated = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); + $notAggregated = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); + + // Nur das erste Ereignis steckt in `hits`. Fiele das zweite hier weg, wäre + // dieser Aufruf dauerhaft verloren — es ist nirgends sonst gezählt. + AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $aggregated->id); + + $this->artisan('press-releases:purge-events')->assertSuccessful(); + + expect(PressReleaseEvent::query()->find($aggregated->id))->toBeNull() + ->and(PressReleaseEvent::query()->find($notAggregated->id))->not->toBeNull(); +}); + +test('the purge refuses to run without a watermark', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + $event = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); + + // Die Marke setzt die Migration beim Deploy. Ist sie weg (von Hand + // gelöscht, Seed übersprungen), ist unbekannt, was bereits in `hits` + // steckt — raten wäre Datenverlust. + AppSetting::query()->whereKey(AggregatePressReleaseViews::WatermarkKey)->delete(); + AppSetting::forgetCached(AggregatePressReleaseViews::WatermarkKey); + + $this->artisan('press-releases:purge-events')->assertExitCode(1); + + expect(PressReleaseEvent::query()->find($event->id))->not->toBeNull(); +}); + +test('the purge dry run counts without deleting', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + $event = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); + + AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $event->id); + + $this->artisan('press-releases:purge-events', ['--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] 1 Ereignisse') + ->assertSuccessful(); + + expect(PressReleaseEvent::query()->find($event->id))->not->toBeNull(); +}); + +test('the retention window is configurable', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(); + $event = purgeTestEvent($release, now()->subDays(40)->toDateTimeString()); + + AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $event->id); + + $this->artisan('press-releases:purge-events', ['--days' => 30])->assertSuccessful(); + + expect(PressReleaseEvent::query()->find($event->id))->toBeNull(); +}); diff --git a/tests/Feature/Web/PressReleaseTrackingTest.php b/tests/Feature/Web/PressReleaseTrackingTest.php index f1125ab7..d10b7fba 100644 --- a/tests/Feature/Web/PressReleaseTrackingTest.php +++ b/tests/Feature/Web/PressReleaseTrackingTest.php @@ -1,15 +1,18 @@ create(array_merge(['slug' => 'tracking-pm', 'published_at' => now()->subDay()], $attributes)); } -test('release detail logs a view event alongside the hit counter', function () { +test('release detail logs a view event without writing to the press release table', function () { /** @var TestCase $this */ $release = trackingRelease(['hits' => 4]); $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); - expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(5); - $event = PressReleaseEvent::query()->sole(); expect($event->press_release_id)->toBe($release->id) ->and($event->type)->toBe(PressReleaseEventType::View) ->and($event->channel)->toBeNull(); + + // Der Request fasst press_releases NICHT an — ein blockierender + // Schreibzugriff auf die große Tabelle hat früher Worker gebunden. + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(4); + + // Erst der Scheduler verbucht den Aufruf im Gesamtzähler. + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(5); +}); + +test('view aggregation is idempotent and does not double count', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 0]); + $url = 'https://businessportal24.test/de/press-release/'.$release->slug; + + $this->get($url)->assertSuccessful(); + $this->get($url)->assertSuccessful(); + + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2); + + // Zweiter Lauf ohne neue Ereignisse darf nichts verändern — die + // Wasserstandsmarke steht bereits hinter den verbuchten Aufrufen. + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(2); + + // Ein weiterer Aufruf wird beim nächsten Lauf nachgetragen. + $this->get($url)->assertSuccessful(); + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(3); +}); + +test('an existing hit count is never counted twice through the event log', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 1000]); + + // Bestand: Ereignisse, die bereits in `hits` stecken. + PressReleaseEvent::factory()->count(5)->create([ + 'press_release_id' => $release->id, + 'type' => PressReleaseEventType::View, + ]); + + // Genau das tut die Migration beim Deploy: Marke auf den Ist-Stand. + AppSetting::setValue( + AggregatePressReleaseViews::WatermarkKey, + (int) PressReleaseEvent::query()->max('id'), + ); + + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1000); + + // Nur neue Aufrufe kommen hinzu. + $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1001); +}); + +test('a missing watermark initialises instead of counting the backlog twice', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 1000]); + + PressReleaseEvent::factory()->count(4)->create([ + 'press_release_id' => $release->id, + 'type' => PressReleaseEventType::View, + ]); + + // Zustand im Deploy-Fenster: Code läuft schon, Migration noch nicht — + // der Scheduler startet den Task ohne gesetzte Marke. + AppSetting::query()->whereKey(AggregatePressReleaseViews::WatermarkKey)->delete(); + Cache::forget('app-setting.'.AggregatePressReleaseViews::WatermarkKey); + + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + // Der Bestand darf NICHT ein zweites Mal aufaddiert werden. + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1000); + + // Ab jetzt zählt nur noch, was neu hinzukommt. + $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1001); +}); + +test('view aggregation ignores share events and walks through in chunks', function () { + /** @var TestCase $this */ + $release = trackingRelease(['hits' => 0]); + + PressReleaseEvent::factory()->count(7)->create([ + 'press_release_id' => $release->id, + 'type' => PressReleaseEventType::View, + ]); + PressReleaseEvent::factory()->count(3)->create([ + 'press_release_id' => $release->id, + 'type' => PressReleaseEventType::Share, + 'channel' => PressReleaseShareChannel::Email, + ]); + + // Chunk kleiner als die Ereignismenge: erzwingt mehrere Durchgänge. + $this->artisan('press-releases:aggregate-views', ['--chunk' => 100])->assertSuccessful(); + + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(7); }); test('release detail shows the public view count', function () { diff --git a/tests/Feature/Web/ReleaseDetailTest.php b/tests/Feature/Web/ReleaseDetailTest.php index 84024c2e..20544097 100644 --- a/tests/Feature/Web/ReleaseDetailTest.php +++ b/tests/Feature/Web/ReleaseDetailTest.php @@ -354,6 +354,10 @@ test('release detail counts a hit', function () { $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful(); + // Der Aufruf landet im Ereignis-Log; in den Gesamtzähler verbucht ihn der + // Scheduler, damit der Request die große Tabelle nicht beschreiben muss. + $this->artisan('press-releases:aggregate-views')->assertSuccessful(); + expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(5); });