web: Stale-while-revalidate-Cache für die öffentlichen Seitendaten
Start-/Kategorieseiten bauten nach Ablauf der 5-Minuten-TTL im Besucher- Request neu (~25-30 Archiv-Queries über ~100k PMs) — Long-Tail-Rubriken waren dadurch praktisch bei jedem Aufruf langsam. WebPageCache nutzt jetzt Cache::flexible([300, 3600]): Innerhalb 5 Min frisch wie bisher, danach wird bis zu 1 h sofort der alte Stand serviert und der Neuaufbau läuft nach der Response (deferred). HomePageData/CategoryPageData/TickerData laufen dafür über die gemeinsame WebPageCache-Konvention (Keys unverändert), die Hub-Kennzahlen über flexible([3600, 14400]). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5011f28f88
commit
bd4ad855ba
6 changed files with 77 additions and 37 deletions
|
|
@ -7,34 +7,31 @@ use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Support\WebPageCache;
|
||||||
use App\Support\WebPortalContext;
|
use App\Support\WebPortalContext;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed-
|
* Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed-
|
||||||
* Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken.
|
* Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken.
|
||||||
*
|
*
|
||||||
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
* Das Ergebnis wird stale-while-revalidate gecacht (WebPageCache): Der Aufbau
|
||||||
* ~30 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
* umfasst ~30 Queries über das gesamte Archiv und darf nicht im Besucher-
|
||||||
* minütlich. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
|
* Request laufen. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
|
||||||
* vor dem Cache-Write wirft.
|
* 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
|
class CategoryPageData
|
||||||
{
|
{
|
||||||
private const CACHE_TTL_SECONDS = 300;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function build(WebPortalContext $context, string $slug): array
|
public function build(WebPortalContext $context, string $slug): array
|
||||||
{
|
{
|
||||||
$cacheKey = sprintf('web.category.%s.%s.%s', $context->portal->value, $context->language, $slug);
|
return WebPageCache::remember(
|
||||||
|
WebPageCache::key('category', [$context->portal->value, $context->language, $slug]),
|
||||||
return Cache::remember(
|
|
||||||
$cacheKey,
|
|
||||||
self::CACHE_TTL_SECONDS,
|
|
||||||
fn (): array => $this->buildFresh($context, $slug)
|
fn (): array => $this->buildFresh($context, $slug)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,29 @@ use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Support\EditorialClock;
|
use App\Support\EditorialClock;
|
||||||
|
use App\Support\WebPageCache;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
|
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
|
||||||
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
|
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
|
||||||
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
|
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
|
||||||
*
|
*
|
||||||
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
* Das Ergebnis wird stale-while-revalidate gecacht (WebPageCache): Der Aufbau
|
||||||
* ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
* umfasst ~25 Queries über das gesamte Archiv und darf nicht im Besucher-
|
||||||
* minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL.
|
* Request laufen — neue Meldungen erscheinen spätestens nach Ablauf der
|
||||||
|
* Frische-TTL beim übernächsten Aufruf.
|
||||||
*/
|
*/
|
||||||
class HomePageData
|
class HomePageData
|
||||||
{
|
{
|
||||||
private const CACHE_TTL_SECONDS = 300;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function build(Portal $portal, string $language = 'de'): array
|
public function build(Portal $portal, string $language = 'de'): array
|
||||||
{
|
{
|
||||||
$cacheKey = sprintf('web.home.%s.%s', $portal->value, $language);
|
return WebPageCache::remember(
|
||||||
|
WebPageCache::key('home', [$portal->value, $language]),
|
||||||
return Cache::remember(
|
|
||||||
$cacheKey,
|
|
||||||
self::CACHE_TTL_SECONDS,
|
|
||||||
fn (): array => $this->buildFresh($portal, $language)
|
fn (): array => $this->buildFresh($portal, $language)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,18 @@ use Illuminate\Support\Facades\Cache;
|
||||||
/**
|
/**
|
||||||
* Datenaufbereitung der Hub-Landing (pressekonto.de): aktiver Tarif-Katalog
|
* Datenaufbereitung der Hub-Landing (pressekonto.de): aktiver Tarif-Katalog
|
||||||
* plus echte Plattform-Kennzahlen statt statischer Marketing-Zahlen.
|
* 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
|
class HubPageData
|
||||||
{
|
{
|
||||||
private const StatsCacheKey = 'web.hub.landing-stats';
|
private const StatsCacheKey = 'web.hub.landing-stats';
|
||||||
|
|
||||||
private const StatsTtlSeconds = 3600;
|
private const StatsFreshSeconds = 3600;
|
||||||
|
|
||||||
|
private const StatsStaleSeconds = 14400;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|
@ -28,7 +34,7 @@ class HubPageData
|
||||||
return [
|
return [
|
||||||
'plans' => Plan::query()->active()->get(),
|
'plans' => Plan::query()->active()->get(),
|
||||||
'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'),
|
'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()
|
$published = PressRelease::query()
|
||||||
->withoutGlobalScope(PortalScope::class)
|
->withoutGlobalScope(PortalScope::class)
|
||||||
->visibleInArchive();
|
->visibleInArchive();
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,25 @@ namespace App\Services\Web;
|
||||||
|
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use App\Support\WebPageCache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ad-Hoc-Ticker-Meldungen der Web-Frontends: die jüngsten veröffentlichten
|
* Ad-Hoc-Ticker-Meldungen der Web-Frontends: die jüngsten veröffentlichten
|
||||||
* Pressemitteilungen des Portals. Zentral hier statt je Seite, damit der
|
* Pressemitteilungen des Portals. Zentral hier statt je Seite, damit der
|
||||||
* Ticker auf allen Unterseiten identisch gefüllt ist; kurz gecacht, weil
|
* Ticker auf allen Unterseiten identisch gefüllt ist; stale-while-revalidate
|
||||||
* der Header auf jeder Seite rendert.
|
* gecacht (WebPageCache), weil der Header auf jeder Seite rendert.
|
||||||
*/
|
*/
|
||||||
class TickerData
|
class TickerData
|
||||||
{
|
{
|
||||||
private const CACHE_TTL_SECONDS = 300;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{time: string, text: string, href: ?string}>
|
* @return list<array{time: string, text: string, href: ?string}>
|
||||||
*/
|
*/
|
||||||
public function items(Portal $portal, string $language): array
|
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()
|
return PressRelease::query()
|
||||||
->forPortalValues([$portal->value, Portal::Both->value])
|
->forPortalValues([$portal->value, Portal::Both->value])
|
||||||
->forLanguage($language)
|
->forLanguage($language)
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,32 @@ use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemeinsame Cache-Konvention der öffentlichen Web-Seitendaten (HomePageData,
|
* Gemeinsame Cache-Konvention der öffentlichen Web-Seitendaten (HomePageData,
|
||||||
* CategoryPageData, NewsroomDetail/NewsroomsPageData, SuchePageData,
|
* CategoryPageData, TickerData, NewsroomDetail/NewsroomsPageData,
|
||||||
* CategoryNavigation). Bündelt die einheitliche TTL und den `web.`-Präfix an
|
* SuchePageData, CategoryNavigation). Bündelt TTL und `web.`-Präfix an einer
|
||||||
* einer Stelle, statt vier verschiedene Konstanten/Magic-Numbers zu pflegen.
|
* 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
|
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;
|
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).
|
* 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
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use App\Enums\Portal;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
use App\Support\WebPageCache;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Livewire\Volt\Volt as LivewireVolt;
|
use Livewire\Volt\Volt as LivewireVolt;
|
||||||
use Tests\TestCase;
|
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');
|
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 () {
|
test('category page data is cached per portal, language and slug but 404s are not cached', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
$category = makeCategoryWithSlug(Portal::Businessportal24, 'Mobilität', 'mobilitaet');
|
$category = makeCategoryWithSlug(Portal::Businessportal24, 'Mobilität', 'mobilitaet');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue