web: Startseiten-Feed cachen + Index gegen Filesort der Feed-Query

Der Livewire-Feed lief auf jeder Startseiten-Impression ungecacht; seine
ORDER-BY-published_at-Query kann wegen portal IN (…) keinen bestehenden
Index zur Sortierung nutzen und filesortete ~10-20k Zeilen (live gemessen
2,3 s pro Aufruf — die Startseite blieb dadurch trotz Seitendaten-Cache
bei 6-9 s TTFB). Zwei Maßnahmen:

- Neuer Index (language, status, deleted_at, published_at): Backward
  Index Scan statt Filesort, Query 2 ms statt 2,3 s — greift auch für
  ungecachte Livewire-Interaktionen (Zeitraum-Filter, tiefe Seiten).
- Feed-Seiten 1-3 laufen über WebPageCache (SWR wie die übrigen
  Seitendaten); gecacht werden nur items + total, der Paginator wird pro
  Request neu gebaut (keine Host-URLs des Cache-Erzeugers im Cache).
  Unbekannte timeframe-Werte aus der URL fallen auf 'all' zurück und
  erreichen weder Query noch Cache-Key. Der doppelte Gesamt-COUNT pro
  Request entfällt; der 'Mehr laden'-Zähler nutzt jetzt den gefilterten
  Total des Paginators.

Lokal mit Live-Datenbestand: Startseite kalt 875 ms (vorher 2,5 s),
warm 11 ms (vorher 72 ms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-20 14:53:09 +00:00
parent bd4ad855ba
commit 3d10cbf709
3 changed files with 151 additions and 15 deletions

View file

@ -55,8 +55,8 @@ test('homepage data is cached per portal and language', function () {
]);
// Innerhalb der TTL liefert HomePageData den gecachten Stand: Lead bleibt
// die alte Meldung. (Der Livewire-Feed ist bewusst ungecacht und zeigt
// neue Meldungen sofort.)
// die alte Meldung. (Auch der Livewire-Feed cached seine vorderen Seiten
// über WebPageCache — neue Meldungen erscheinen nach Ablauf der Frische-TTL.)
$cachedLead = $this->get('https://presseecho.test/de')
->assertSuccessful()
->viewData('leadRelease');
@ -99,6 +99,47 @@ test('web page cache serves stale data instantly instead of rebuilding in the re
expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-2');
});
test('the press release feed caches its front pages but not deep pages or junk timeframes', function () {
/** @var TestCase $this */
$category = makeCategoryWithSlug(Portal::Presseecho, 'Feed-Rubrik', 'feed-rubrik');
$company = Company::factory()->presseecho()->create();
PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Feed-Meldung eins',
'slug' => 'feed-meldung-eins',
'published_at' => now()->subHour(),
]);
$this->get('https://presseecho.test/de')
->assertSuccessful()
->assertSeeText('Feed-Meldung eins');
expect(Cache::has('web.feed.presseecho.de.all.page.1'))->toBeTrue();
// Tiefe Seiten bleiben ungecacht (Schutz vor unbegrenztem Key-Raum).
$this->get('https://presseecho.test/de?page=25')->assertSuccessful();
expect(Cache::has('web.feed.presseecho.de.all.page.25'))->toBeFalse();
// Unbekannte timeframe-Werte aus der URL fallen auf "all" zurück und
// erzeugen keinen eigenen (poisonbaren) Cache-Key.
$this->get('https://presseecho.test/de?timeframe=boesewert')->assertSuccessful();
expect(Cache::has('web.feed.presseecho.de.boesewert.page.1'))->toBeFalse();
// Der Cache hält nur items + total — der Paginator (inkl. Host-URLs der
// Pagination-Links) wird pro Request neu gebaut, damit der gecachte Feed
// nicht den Host des Cache-Erzeugers an andere Besucher ausliefert.
expect(Cache::get('web.feed.presseecho.de.all.page.1'))
->toBeArray()
->toHaveKeys(['items', 'total']);
});
test('category page data is cached per portal, language and slug but 404s are not cached', function () {
/** @var TestCase $this */
$category = makeCategoryWithSlug(Portal::Businessportal24, 'Mobilität', 'mobilitaet');