Bisher 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 dort eine Schemaänderung läuft — die wartenden Requests belegten dabei PHP-FPM-Worker und DB-Verbindungen, bis die ganze Seite stand. Der Request schreibt jetzt nur noch in die kleine Append-Tabelle press_release_events, und selbst dabei wird jeder Fehler geschluckt und gemeldet: Ein Aufruf-Zähler darf eine Seite niemals langsamer oder kaputt machen. Die Summierung in press_releases.hits übernimmt gebündelt der Scheduler. Der Fortschritt läuft über eine Wasserstandsmarke (höchste bereits verbuchte Event-ID) statt über einen Zwischenspeicher, damit bei einem Neustart kein Aufruf verloren geht. Zähler und Marke werden je Durchgang gemeinsam committet — sonst wäre nur der Durchgang wiederanlaufsicher, nicht der Abbruch darin, und der nächste Lauf verbuchte dieselben Aufrufe doppelt. Eine Migration setzt die Marke beim Deploy auf den Ist-Stand; ohne sie würde der erste Lauf den gesamten Altbestand ein zweites Mal aufaddieren. Weil das Log damit mit dem Traffic wächst und als einzige Log-Tabelle des Systems keine Bereinigung hatte, begrenzt press-releases:purge-events es auf einen Aufbewahrungszeitraum (Default 395 Tage = 13 Monate, damit der Vorjahresmonats-Vergleich möglich bleibt). Gelöscht wird ausschließlich unterhalb der Wasserstandsmarke — alles darüber ist noch nicht in hits verbucht und wäre sonst dauerhaft verloren. Fehlt die Marke, bricht der Lauf ab, statt zu raten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
3.8 KiB
PHP
98 lines
3.8 KiB
PHP
<?php
|
|
|
|
use App\Console\Commands\AggregatePressReleaseViews;
|
|
use App\Enums\PressReleaseEventType;
|
|
use App\Models\AppSetting;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseEvent;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* press-releases:purge-events — begrenzt das Ereignis-Log auf den
|
|
* Aufbewahrungszeitraum, ohne noch nicht verbuchte Aufrufe zu verlieren.
|
|
*/
|
|
function purgeTestEvent(PressRelease $release, string $createdAt): PressReleaseEvent
|
|
{
|
|
$event = PressReleaseEvent::create([
|
|
'press_release_id' => $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();
|
|
});
|