diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php index 0c641f38..a593b886 100644 --- a/app/Services/Web/CategoryPageData.php +++ b/app/Services/Web/CategoryPageData.php @@ -7,34 +7,31 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Scopes\PortalScope; +use App\Support\WebPageCache; use App\Support\WebPortalContext; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Cache; /** * Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed- * Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken. * - * Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst - * ~30 Queries über das gesamte Archiv und die Daten ändern sich höchstens - * minütlich. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort() - * vor dem Cache-Write wirft. + * Das Ergebnis wird stale-while-revalidate gecacht (WebPageCache): Der Aufbau + * umfasst ~30 Queries über das gesamte Archiv und darf nicht im Besucher- + * Request laufen. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort() + * vor dem Cache-Write wirft; wird eine Rubrik nachträglich deaktiviert, wirft + * der deferred Neuaufbau ins Leere und der alte Stand verschwindet spätestens + * mit Ablauf der Stale-Grenze. */ class CategoryPageData { - private const CACHE_TTL_SECONDS = 300; - /** * @return array */ public function build(WebPortalContext $context, string $slug): array { - $cacheKey = sprintf('web.category.%s.%s.%s', $context->portal->value, $context->language, $slug); - - return Cache::remember( - $cacheKey, - self::CACHE_TTL_SECONDS, + return WebPageCache::remember( + WebPageCache::key('category', [$context->portal->value, $context->language, $slug]), fn (): array => $this->buildFresh($context, $slug) ); } diff --git a/app/Services/Web/HomePageData.php b/app/Services/Web/HomePageData.php index 40700bf0..b17699cd 100644 --- a/app/Services/Web/HomePageData.php +++ b/app/Services/Web/HomePageData.php @@ -8,33 +8,29 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Support\EditorialClock; +use App\Support\WebPageCache; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Cache; /** * Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/ * meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus" * (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData. * - * Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst - * ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens - * minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL. + * Das Ergebnis wird stale-while-revalidate gecacht (WebPageCache): Der Aufbau + * umfasst ~25 Queries über das gesamte Archiv und darf nicht im Besucher- + * Request laufen — neue Meldungen erscheinen spätestens nach Ablauf der + * Frische-TTL beim übernächsten Aufruf. */ class HomePageData { - private const CACHE_TTL_SECONDS = 300; - /** * @return array */ public function build(Portal $portal, string $language = 'de'): array { - $cacheKey = sprintf('web.home.%s.%s', $portal->value, $language); - - return Cache::remember( - $cacheKey, - self::CACHE_TTL_SECONDS, + return WebPageCache::remember( + WebPageCache::key('home', [$portal->value, $language]), fn (): array => $this->buildFresh($portal, $language) ); } diff --git a/app/Services/Web/HubPageData.php b/app/Services/Web/HubPageData.php index e5f8d402..5d4a91e1 100644 --- a/app/Services/Web/HubPageData.php +++ b/app/Services/Web/HubPageData.php @@ -13,12 +13,18 @@ use Illuminate\Support\Facades\Cache; /** * Datenaufbereitung der Hub-Landing (pressekonto.de): aktiver Tarif-Katalog * plus echte Plattform-Kennzahlen statt statischer Marketing-Zahlen. + * + * Die Kennzahlen ändern sich träge, ihr Aufbau zählt aber übers Gesamtarchiv + * (inkl. whereHas über alle Firmen) → stale-while-revalidate mit langem + * Fenster, damit der Aufbau nie im Besucher-Request läuft. */ class HubPageData { private const StatsCacheKey = 'web.hub.landing-stats'; - private const StatsTtlSeconds = 3600; + private const StatsFreshSeconds = 3600; + + private const StatsStaleSeconds = 14400; /** * @return array @@ -28,7 +34,7 @@ class HubPageData return [ 'plans' => Plan::query()->active()->get(), 'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'), - 'hubStats' => Cache::remember(self::StatsCacheKey, self::StatsTtlSeconds, function (): array { + 'hubStats' => Cache::flexible(self::StatsCacheKey, [self::StatsFreshSeconds, self::StatsStaleSeconds], function (): array { $published = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->visibleInArchive(); diff --git a/app/Services/Web/TickerData.php b/app/Services/Web/TickerData.php index 80fa5aea..ea6aaaa3 100644 --- a/app/Services/Web/TickerData.php +++ b/app/Services/Web/TickerData.php @@ -4,27 +4,25 @@ namespace App\Services\Web; use App\Enums\Portal; use App\Models\PressRelease; -use Illuminate\Support\Facades\Cache; +use App\Support\WebPageCache; use Illuminate\Support\Str; /** * Ad-Hoc-Ticker-Meldungen der Web-Frontends: die jüngsten veröffentlichten * Pressemitteilungen des Portals. Zentral hier statt je Seite, damit der - * Ticker auf allen Unterseiten identisch gefüllt ist; kurz gecacht, weil - * der Header auf jeder Seite rendert. + * Ticker auf allen Unterseiten identisch gefüllt ist; stale-while-revalidate + * gecacht (WebPageCache), weil der Header auf jeder Seite rendert. */ class TickerData { - private const CACHE_TTL_SECONDS = 300; - /** * @return list */ public function items(Portal $portal, string $language): array { - $cacheKey = sprintf('web.ticker.%s.%s', $portal->value, $language); + $cacheKey = WebPageCache::key('ticker', [$portal->value, $language]); - return Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($portal, $language): array { + return WebPageCache::remember($cacheKey, function () use ($portal, $language): array { return PressRelease::query() ->forPortalValues([$portal->value, Portal::Both->value]) ->forLanguage($language) diff --git a/app/Support/WebPageCache.php b/app/Support/WebPageCache.php index 9900e03c..6b3483db 100644 --- a/app/Support/WebPageCache.php +++ b/app/Support/WebPageCache.php @@ -7,17 +7,32 @@ use Illuminate\Support\Facades\Cache; /** * Gemeinsame Cache-Konvention der öffentlichen Web-Seitendaten (HomePageData, - * CategoryPageData, NewsroomDetail/NewsroomsPageData, SuchePageData, - * CategoryNavigation). Bündelt die einheitliche TTL und den `web.`-Präfix an - * einer Stelle, statt vier verschiedene Konstanten/Magic-Numbers zu pflegen. + * CategoryPageData, TickerData, NewsroomDetail/NewsroomsPageData, + * SuchePageData, CategoryNavigation). Bündelt TTL und `web.`-Präfix an einer + * Stelle, statt mehrere Konstanten/Magic-Numbers zu pflegen. + * + * Stale-while-revalidate (Cache::flexible): Innerhalb der Frische-TTL kommt + * der Wert direkt aus dem Cache. Danach wird bis zur Stale-Grenze sofort der + * alte Stand serviert und der Neuaufbau läuft NACH der Response (deferred) — + * Besucher zahlen den teuren Seitenaufbau (~25–30 Archiv-Queries) nicht mehr + * im Request, solange die Seite mindestens einmal pro Stale-Fenster besucht + * wird. Erst nach Ablauf der Stale-Grenze baut der Request wieder synchron. */ class WebPageCache { /** - * Einheitliche Cache-Dauer der Web-Seitendaten in Sekunden. + * Frische-Fenster der Web-Seitendaten in Sekunden: solange gilt der + * Cache-Eintrag als aktuell und wird ohne Neuaufbau ausgeliefert. */ public const TtlSeconds = 300; + /** + * Stale-Grenze in Sekunden (ab Cache-Write): bis hierhin wird ein + * abgelaufener Eintrag noch serviert, während der Neuaufbau im + * Hintergrund läuft. + */ + public const StaleSeconds = 3600; + /** * Baut einen `web.`-Cache-Key aus Segmenten (leere Segmente entfallen). * @@ -41,6 +56,6 @@ class WebPageCache */ public static function remember(string $key, Closure $callback): mixed { - return Cache::remember($key, self::TtlSeconds, $callback); + return Cache::flexible($key, [self::TtlSeconds, self::StaleSeconds], $callback); } } diff --git a/tests/Feature/Web/PageDataCacheTest.php b/tests/Feature/Web/PageDataCacheTest.php index 3c9fca0e..9e2ef5a5 100644 --- a/tests/Feature/Web/PageDataCacheTest.php +++ b/tests/Feature/Web/PageDataCacheTest.php @@ -4,6 +4,7 @@ use App\Enums\Portal; use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; +use App\Support\WebPageCache; use Illuminate\Support\Facades\Cache; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -71,6 +72,33 @@ test('homepage data is cached per portal and language', function () { expect($freshLead->title)->toBe('Neue Meldung nach Cache-Aufbau'); }); +test('web page cache serves stale data instantly instead of rebuilding in the request', function () { + /** @var TestCase $this */ + $calls = 0; + $build = function () use (&$calls): string { + $calls++; + + return 'version-'.$calls; + }; + + expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-1'); + expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-1'); + expect($calls)->toBe(1); + + // Frische-TTL (300s) überschritten, Stale-Grenze (3600s) nicht: Der + // Besucher bekommt sofort den alten Stand; der Neuaufbau läuft deferred + // nach der Response, nicht synchron im Request. + $this->travel(10)->minutes(); + + expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-1'); + expect($calls)->toBe(1); + + // Nach Ablauf der Stale-Grenze baut der Request wieder synchron. + $this->travel(2)->hours(); + + expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-2'); +}); + 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');