From 56154d8c4263ebf0881631c935052afed59a6702 Mon Sep 17 00:00:00 2001 From: Nigel Tatschner Date: Sun, 31 May 2026 16:32:10 +0100 Subject: [PATCH] fix(web): bypass data cache for large reference categories (item, vehicle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next 15 hardcodes a 2 MB per-entry limit on the fetch data cache. The items reference listing currently weighs ~3.4 MB and the vehicle listing ~4 MB (per memory/wiki-reference-page-sizes.md), so every render that calls `getCategoryBundle('item')` / `getCategoryBundle('vehicle')` logged: Failed to set Next.js data cache for http://starstats-api:8080/v1/reference/item, items over 2MB can not be cached (3441446 bytes) The fetch still succeeded — pages rendered fine — but the cache attempt was wasted serialization work and the recurring warning muddied the prod logs. Fix: `kbCacheOpts` now takes the category as an optional arg and returns `cache: 'no-store'` for the known-large set (LARGE_CATEGORIES = item + vehicle). Other categories (weapon, location) keep the 1h revalidate. STARSTATS_DISABLE_FETCH_CACHE=1 still wins for the Playwright scenario-isolation path. React's request-level fetch dedup still applies — same-URL fetches within a single server render share an upstream call regardless of cache mode. The cost is one 3.4 MB inbound transfer per page render that touches items, which is the cost we were already paying because Next refused to cache it anyway. If a third category ever crosses 2 MB the symptom is the same log line; add it to LARGE_CATEGORIES. --- apps/web/src/lib/reference.test.ts | 61 ++++++++++++++++++++++++++++++ apps/web/src/lib/reference.ts | 44 ++++++++++++++++++--- 2 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/reference.test.ts diff --git a/apps/web/src/lib/reference.test.ts b/apps/web/src/lib/reference.test.ts new file mode 100644 index 00000000..37140737 --- /dev/null +++ b/apps/web/src/lib/reference.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { kbCacheOpts, LARGE_CATEGORIES } from './reference'; +import type { ReferenceCategory } from './reference-types'; + +// Snapshot + restore the env knob so a test that flips it doesn't +// poison the others. vitest doesn't isolate `process.env` per-test. +let savedDisable: string | undefined; +beforeEach(() => { + savedDisable = process.env.STARSTATS_DISABLE_FETCH_CACHE; + delete process.env.STARSTATS_DISABLE_FETCH_CACHE; +}); +afterEach(() => { + if (savedDisable === undefined) { + delete process.env.STARSTATS_DISABLE_FETCH_CACHE; + } else { + process.env.STARSTATS_DISABLE_FETCH_CACHE = savedDisable; + } +}); + +describe('LARGE_CATEGORIES', () => { + it('marks item and vehicle as large (>2 MB)', () => { + expect(LARGE_CATEGORIES.has('item')).toBe(true); + expect(LARGE_CATEGORIES.has('vehicle')).toBe(true); + }); + + it('leaves weapon and location cacheable (<2 MB)', () => { + expect(LARGE_CATEGORIES.has('weapon')).toBe(false); + expect(LARGE_CATEGORIES.has('location')).toBe(false); + }); +}); + +describe('kbCacheOpts', () => { + it('returns a 1h revalidate by default (no category arg)', () => { + expect(kbCacheOpts()).toEqual({ next: { revalidate: 3600 } }); + }); + + it('caches small categories with the 1h revalidate', () => { + const small: ReferenceCategory[] = ['weapon', 'location']; + for (const cat of small) { + expect(kbCacheOpts(cat)).toEqual({ next: { revalidate: 3600 } }); + } + }); + + it('bypasses the data cache for large categories', () => { + const large: ReferenceCategory[] = ['item', 'vehicle']; + for (const cat of large) { + expect(kbCacheOpts(cat)).toEqual({ cache: 'no-store' }); + } + }); + + it('honours STARSTATS_DISABLE_FETCH_CACHE for every category', () => { + process.env.STARSTATS_DISABLE_FETCH_CACHE = '1'; + const every: ReferenceCategory[] = ['vehicle', 'weapon', 'item', 'location']; + for (const cat of every) { + expect(kbCacheOpts(cat)).toEqual({ cache: 'no-store' }); + } + // And the no-category variant. + expect(kbCacheOpts()).toEqual({ cache: 'no-store' }); + }); +}); diff --git a/apps/web/src/lib/reference.ts b/apps/web/src/lib/reference.ts index c6e0c86c..bf64cca4 100644 --- a/apps/web/src/lib/reference.ts +++ b/apps/web/src/lib/reference.ts @@ -56,22 +56,54 @@ import { // `server-only`. export * from './reference-types'; +/** + * Categories whose full listing exceeds Next 15's hardcoded 2 MB + * per-entry data-cache limit. Trying to cache them logs a recurring + * "Failed to set Next.js data cache for … items over 2MB can not + * be cached (N bytes)" + * warning on every fetch with no actual cache benefit. Opt out with + * `no-store` so the warning stops and we don't pay the futile + * serialization cost on every request. React's request-level fetch + * dedup still applies — same-URL fetches within a single server + * render share an upstream call regardless of cache mode. + * + * Sizes as of 2026-05-31: + * - item: ~3.4 MB + * - vehicle: ~4 MB (per memory: wiki-reference-page-sizes) + * `weapon` + `location` stay <1 MB and remain cacheable. + * + * If another category grows past 2 MB, add it here; the + * symptom is the cache-warning log line above. + */ +/** @internal exported for testing */ +export const LARGE_CATEGORIES: ReadonlySet = new Set([ + 'item', + 'vehicle', +]); + /** * Per-fetch cache directive for reference endpoints. * * Production: 1h revalidate is fine — wiki sync is daily, an hour - * stale is invisible to users. + * stale is invisible to users. Large categories (see + * `LARGE_CATEGORIES`) bypass the data cache; everything else gets + * the 1h revalidate. * * Playwright e2e: cache leaks between scenarios because Next holds * `revalidate` responses across `page.goto()` calls within a single * dev-server lifetime. Setting `STARSTATS_DISABLE_FETCH_CACHE=1` - * (the Playwright webServer env) flips to `no-store` so each - * scenario's `setScenario` is honored by the next render. + * (the Playwright webServer env) flips to `no-store` for every + * category so each scenario's `setScenario` is honored by the next + * render. */ -function kbCacheOpts(): RequestInit { +/** @internal exported for testing */ +export function kbCacheOpts(category?: ReferenceCategory): RequestInit { if (process.env.STARSTATS_DISABLE_FETCH_CACHE === '1') { return { cache: 'no-store' }; } + if (category && LARGE_CATEGORIES.has(category)) { + return { cache: 'no-store' }; + } return { next: { revalidate: 3600 } } as RequestInit; } @@ -102,7 +134,7 @@ export async function getCategoryBundle( try { const resp = await fetch(`${apiBase()}/v1/reference/${category}`, { method: 'GET', - ...kbCacheOpts(), + ...kbCacheOpts(category), }); if (!resp.ok) { // Log non-2xx; reference data is cosmetic-but-not-invisible, @@ -171,7 +203,7 @@ export async function getEntityDetail( `${apiBase()}/v1/reference/${category}/slug/${encodeURIComponent(slug)}`, { method: 'GET', - ...kbCacheOpts(), + ...kbCacheOpts(category), }, ); if (resp.status === 404) return { kind: 'not_found' };