From a6cc3cafa9a886513293a43af155d59de3aef2c6 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 1 Oct 2026 15:16:48 +0200 Subject: [PATCH] fix(catalog): read furnidata from one cache, purge the gamedata edge on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Furniture was not always loading completely because the same file was cached twice and nobody could reach the client. The catalog items loader kept its own 30s TTL copy of FurnitureData.json next to the mtime-validated cache in `furni-data.ts`. An import cleared only the second one, so the catalog table kept serving pre-import furnidata — empty descriptions and revisions — until the TTL ran out. The loader now reads through `readFurniData`, which revalidates on mtime+size and is reset by every write, so there is exactly one cache and it cannot go stale on its own. `invalidateFurniDataCache` and its single call site are gone with it. The client was worse: nginx served all of /gamedata/ with `max-age=604800`, and the `cms-gamedata` purge that would have fixed it hung off the catalog Git export, which is disabled in production. A freshly imported item was invisible in the client for up to seven days no matter how often you imported. - `writeFurniData` now purges the gamedata edge tag itself. One place covers import, batch, resync, regen, nitro-editor, translate and dedupe. It is fire-and-forget and swallowed at every level: a stale edge copy is bounded by the edge TTL, so a failed purge must never fail an import. - nginx splits /gamedata/ by how mutable the content is: config/ gets `max-age=300, must-revalidate`, bundled/ `max-age=3600, must-revalidate`, and the content-addressed trees (c_images, album*, clothes) keep the long TTL. `must-revalidate` is the point — the client now revalidates instead of replaying the old body. All three keep `Cache-Tag: cms-gamedata` so the purge still reaches them. - A 30-minute safety-net purge in the jobs worker covers the case where Cloudflare was unreachable at write time. --- deployment/proxy/nginx-cms.conf | 50 +++++++++++++++++++ scripts/jobs-worker.ts | 18 +++++++ src/actions/catalog-items.ts | 9 ++-- src/lib/services/catalog-items-loader.ts | 63 ++++++++++-------------- src/lib/services/furni-data.ts | 29 +++++++++++ 5 files changed, 125 insertions(+), 44 deletions(-) diff --git a/deployment/proxy/nginx-cms.conf b/deployment/proxy/nginx-cms.conf index 6446a38d..04725b77 100644 --- a/deployment/proxy/nginx-cms.conf +++ b/deployment/proxy/nginx-cms.conf @@ -233,6 +233,56 @@ server { location = /gamedata { return 301 /gamedata/config/; } location = /gamedata/ { return 301 /gamedata/config/; } + + # ─── Gamedata: drie cache-klassen, want niet alles onder /gamedata/ is + # even veranderlijk. + # + # Dit pad had één regel voor de hele boom: `max-age=604800` (7 dagen). De + # Habbo-client haalt FurnitureData.json hier op, dus na een import bleef het + # client-side dagenlang de oude versie tonen — een nieuw geïmporteerd + # meubel was gewoon onzichtbaar. De purge van de `cms-gamedata`-tag + # (edge-cache.ts) raakt alleen de Cloudflare-kopie, niet de browser. + # + # 1. config/ — FurnitureData.json + de vertaalde bestanden. Verandert + # bij elke import. Kort, en `must-revalidate` sluit de + # "stuur uit de cache"-route uit zodat de client na de + # TTL een 304 vraagt in plaats van de oude body te hergebruiken. + # 2. bundled/ — nitro-bundles per sprite. De inhoud kan veranderen zonder + # dat de bestandsnaam verandert (schalen, repareren), dus + # ook revalideren, maar minder vaak: ze worden veel vaker + # opgehaald dan ze worden geschreven. + # 3. alles wat overblijft (c_images, album*, clothes, …) — content-addressed + # of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang. + location ^~ /gamedata/config/ { + alias /var/www/Gamedata/config/; + add_header Cache-Control "public, max-age=300, must-revalidate"; + access_log off; + add_header Cache-Tag "cms-gamedata"; + + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + if ($cors_headers) { + add_header Access-Control-Max-Age 1728000; + add_header Content-Type "text/plain; charset=utf-8"; + return 204; + } + } + + location ^~ /gamedata/bundled/ { + alias /var/www/Gamedata/bundled/; + add_header Cache-Control "public, max-age=3600, must-revalidate"; + access_log off; + add_header Cache-Tag "cms-gamedata"; + + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + if ($cors_headers) { + add_header Access-Control-Max-Age 1728000; + add_header Content-Type "text/plain; charset=utf-8"; + return 204; + } + } + location /gamedata/ { alias /var/www/Gamedata/; add_header Cache-Control "public, max-age=604800"; diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 9bf4f092..771caf40 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -464,6 +464,24 @@ async function main() { logger.info("Scheduled: nitro auto-clean (daily 02:00)", { module: "jobs", }); + + new Cron("*/30 * * * *", () => { + // Purge the gamedata edge tag periodically as a safety net in case a + // single import failed to emit a purge (e.g. Cloudflare disabled at the + // moment of write). Without it, a long TTL on /gamedata/ would keep the + // client stuck on old FurnitureData.json until the browser or CDN cache + // expired. No-op when Cloudflare is not configured. + import("../src/lib/edge-cache") + .then(({ EDGE_CACHE_TAGS, purgeEdgeCache }) => + purgeEdgeCache([EDGE_CACHE_TAGS.gamedata], "jobs-safety-net"), + ) + .catch((e) => + captureWorkerError(e, "Gamedata edge purge (safety net) failed"), + ); + }); + logger.info("Scheduled: gamedata edge purge safety net (every 30 min)", { + module: "jobs", + }); await Promise.all([ backupEmulatorJar(), cleanupOldLogs(), diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 30d438c2..306eb112 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -413,9 +413,6 @@ export async function translateCatalogItems(input: { }; } const { items } = parsed.data; - const { invalidateFurniDataCache } = await import( - "@/lib/services/catalog-items-loader" - ); const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); const { patchLocalizedFurniDataEntries } = await import( "@/lib/services/furni-data-i18n" @@ -510,9 +507,9 @@ export async function translateCatalogItems(input: { furniPatches.length > 0 ? await patchFurniEntryNames(furniPatches) : { updated: 0, inserted: 0 }; - if (furniResult.updated > 0 || furniResult.inserted > 0) { - invalidateFurniDataCache(); - } + // `furniResult` needs no follow-up: `patchFurniEntryNames` writes through + // `writeFurniData`, which resets the shared in-process cache and purges + // the gamedata edge tag itself. if (localizedEntries.length > 0) { try { diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index b3c9cf4b..524955a4 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -1,52 +1,39 @@ -import { promises as fs } from "node:fs"; import { asc, sql } from "drizzle-orm"; import { numericValue } from "@/features/catalog/domain/offer-input"; import { CatalogPages, CatalogPagesBc, db, queryRows } from "@/lib/db"; -import { getFurnitureDataPath } from "@/lib/services/furni-data"; +import { readFurniData } from "@/lib/services/furni-data"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; -// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O -let furniDataCache: { - roomitemtypes?: { - furnitype?: Array<{ - id: number; - description?: string; - classname?: string; - revision?: number; - }>; - }; - wallitemtypes?: { - furnitype?: Array<{ - id: number; - description?: string; - classname?: string; - revision?: number; - }>; - }; -} | null = null; -let furniDataCacheTime = 0; -const FURNI_CACHE_TTL = 30_000; // 30 seconds +interface FurnitypeEntry { + id: number; + description?: string; + classname?: string; + revision?: number; +} -async function getFurnitureData() { - if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL) - return furniDataCache; - const furniDataPath = await getFurnitureDataPath(); +interface FurnitureData { + roomitemtypes?: { furnitype?: FurnitypeEntry[] }; + wallitemtypes?: { furnitype?: FurnitypeEntry[] }; +} + +/** + * FurnitureData.json via the shared reader. + * + * This module used to keep its own 30s TTL copy of the file next to the + * mtime-validated cache in `furni-data.ts`. Two caches over one file meant an + * import cleared only the first one, so this loader kept serving the + * pre-import furnidata (empty descriptions and revisions) until the TTL ran + * out. `readFurniData` revalidates on `mtimeMs`+`size` and is reset by every + * write, so there is now exactly one cache and it cannot go stale on its own. + */ +async function getFurnitureData(): Promise { try { - const raw = await fs.readFile(furniDataPath, "utf-8"); - furniDataCache = JSON.parse(raw); - furniDataCacheTime = Date.now(); - return furniDataCache; + return (await readFurniData()) as FurnitureData; } catch { return null; } } -/** Invalidate the FurnitureData cache after writes */ -export function invalidateFurniDataCache() { - furniDataCache = null; - furniDataCacheTime = 0; -} - interface RawItem { id: number; itemIds: string; @@ -292,7 +279,7 @@ export async function loadCatalogItemsData( spriteToBaseId.set(b.spriteId, b.id); } - // Read FurnitureData.json (cached in memory) + // Read FurnitureData.json through the shared mtime-validated reader. const foundBaseIds = new Set(); const furniDescriptionMap: Record = {}; const furniRevisionMap: Record< diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index ae9b8ef5..13b4b22f 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -277,6 +277,35 @@ export async function writeFurniData( // The file just changed: force the next read to reparse instead of serving // a possibly-mutated cached object. cachedFurniData = null; + void purgeFurniGamedataEdge( + `furnidata written (${targets.length} target(s))`, + ); +} + +/** + * Drop the edge copy of every gamedata file after a furniture write. + * + * The Habbo client reads FurnitureData.json from /gamedata/, which nginx + * serves with its own TTL and Cloudflare with an edge TTL. Neither knows + * about a write that happened on disk, so without this purge a freshly + * imported item stays invisible in the client until both expire. The purge + * used to hang off the catalog Git export, which is disabled in production, + * so in practice it never ran. + * + * Fire-and-forget and swallowed on every level: a stale edge copy is bounded + * by the edge TTL, so failing to purge must never fail the import itself. + * Standalone scripts reach this through `tsx` without the react-server + * condition, where `server-only` throws on import, hence the try/catch. + */ +async function purgeFurniGamedataEdge(reason: string): Promise { + try { + const { EDGE_CACHE_TAGS, purgeEdgeCache } = await import( + "@/lib/edge-cache" + ); + await purgeEdgeCache([EDGE_CACHE_TAGS.gamedata], reason); + } catch { + /* no Cloudflare credentials, or a non-server runtime: nothing to purge */ + } } export function buildFurniEntry(params: {