From 9c513d83f88967d0793f2fd6a40a8947c5e4aef2 Mon Sep 17 00:00:00 2001 From: jm Date: Thu, 29 Oct 2026 17:43:25 +0100 Subject: [PATCH 1/2] JCU: expose SSR cache hits, misses and hit rate --- config/config.example.yml | 3 ++ scripts/ssr-cache-stats.sh | 11 ++++++ scripts/test-ssr-cache-stats.js | 68 +++++++++++++++++++++++++++++---- server.ts | 15 ++++---- src/backend/ssr-cache-stats.ts | 28 +++++++++++++- 5 files changed, 108 insertions(+), 17 deletions(-) create mode 100755 scripts/ssr-cache-stats.sh diff --git a/config/config.example.yml b/config/config.example.yml index 37ae63052c5..ff1e99f1bee 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -108,6 +108,9 @@ cache: debug: false # Lets operators on this machine see what the caches below hold: GET /app/cache-stats (JSON). # Each Node process reports its own cache. Off by default. + # hits/misses count request lookups since process start, including stale pages served as hits. + # hitRate = hits / (hits + misses), or null before any lookups. Bypassed requests are not counted. + # Poll all pm2 workers with: bash scripts/ssr-cache-stats.sh [container] [base-port] [workers] stats: enabled: false # 0 = answer on the UI port, but only to requests made directly from this host: connecting from 127.0.0.1 and diff --git a/scripts/ssr-cache-stats.sh b/scripts/ssr-cache-stats.sh new file mode 100755 index 00000000000..263d09a5f13 --- /dev/null +++ b/scripts/ssr-cache-stats.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Requires cache.serverSide.stats.enabled and stats.port > 0 in config.prod.yml. +# Usage: bash scripts/ssr-cache-stats.sh [container] [base-port] [workers] +C=${1:-dspace-angular443} +BASE=${2:-4001} +N=${3:-8} + +for ((i = 0; i < N; i++)); do + p=$((BASE + i)) + docker exec "$C" node -e "fetch('http://127.0.0.1:$p/app/cache-stats').then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}).then(d=>console.log('w'+d.instance,'up',d.uptimeSeconds+'s','rss',Math.round(d.memory.rss/1e6)+'MB',d.caches.map(c=>c.name+':'+(c.enabled?'on':'off')+' n='+c.entryCount+' stale='+c.staleCount+' '+Math.round(c.totalHtmlBytes/1024)+'KB/max'+c.maxEntries+' hits='+c.hits+' misses='+c.misses+' hit='+(c.hitRate==null?'n/a':(100*c.hitRate).toFixed(1)+'%')).join(' | '))).catch(e=>{console.error('$p ERR',e.message);process.exitCode=1})" +done diff --git a/scripts/test-ssr-cache-stats.js b/scripts/test-ssr-cache-stats.js index 05644bf5c56..d2a71661603 100644 --- a/scripts/test-ssr-cache-stats.js +++ b/scripts/test-ssr-cache-stats.js @@ -2,14 +2,13 @@ const assert = require('node:assert/strict'); const { once } = require('node:events'); const { test } = require('node:test'); const { setTimeout: sleep } = require('node:timers/promises'); -const { LRUCache } = require('lru-cache'); process.env.TS_NODE_PROJECT = 'tsconfig.ts-node.json'; // tsconfig.json uses moduleResolution 'bundler', which ts-node cannot combine with the commonjs output it needs. process.env.TS_NODE_COMPILER_OPTIONS = JSON.stringify({ module: 'commonjs', moduleResolution: 'node10' }); require('ts-node/register/transpile-only'); const { - snapshotCache, startSsrCacheStatsServer, isDirectLoopbackRequest + SsrPageCache, snapshotCache, startSsrCacheStatsServer, isDirectLoopbackRequest } = require('../src/backend/ssr-cache-stats.ts'); const statsOf = (caches) => ({ @@ -17,8 +16,54 @@ const statsOf = (caches) => ({ memory: { rss: 1, heapUsed: 1 }, caches }); +test('request lookups count hits and misses independently of cache population and stats polling', () => { + const bot = new SsrPageCache({ max: 2 }); + const anonymous = new SsrPageCache({ max: 2 }); + bot.set('/page', { page: 'html' }); + assert.equal(bot.get('/page').page, 'html'); + assert.equal(bot.has('/page'), true); + assert.equal(snapshotCache('bot', bot, true).hitRate, null); + + assert.equal(bot.getForRequest('/page').page, 'html'); + assert.equal(bot.getForRequest('/missing'), undefined); + assert.equal(bot.getForRequest('/page').page, 'html'); + for (let i = 0; i < 2; i++) { + const stats = snapshotCache('bot', bot, true); + assert.equal(stats.hits, 2); + assert.equal(stats.misses, 1); + assert.equal(stats.hitRate, 2 / 3); + } + const other = snapshotCache('anonymous', anonymous, true); + assert.equal(other.hits, 0); + assert.equal(other.misses, 0); + assert.equal(other.hitRate, null); +}); + +test('expired pages count as hits only when served stale', async () => { + for (const allowStale of [true, false]) { + const cache = new SsrPageCache({ max: 2, ttl: 1, allowStale }); + cache.set('/expired', { page: 'html' }); + await sleep(10); + assert.equal(cache.getForRequest('/expired')?.page, allowStale ? 'html' : undefined); + assert.equal(cache.hits, allowStale ? 1 : 0); + assert.equal(cache.misses, allowStale ? 0 : 1); + assert.equal(cache.getForRequest('/expired'), undefined); + assert.equal(cache.misses, allowStale ? 1 : 2); + } +}); + +test('eviction does not reset request counters', () => { + const cache = new SsrPageCache({ max: 1 }); + cache.set('/first', { page: 'first' }); + cache.getForRequest('/first'); + cache.set('/second', { page: 'second' }); + cache.getForRequest('/first'); + assert.equal(cache.hits, 1); + assert.equal(cache.misses, 1); +}); + test('snapshot reports entries, HTML byte sizes and TTL without changing LRU order', () => { - const cache = new LRUCache({ max: 3, ttl: 60000 }); + const cache = new SsrPageCache({ max: 3, ttl: 60000 }); cache.set('/small', { page: 'é', headers: { 'content-type': 'text/html' } }); cache.set('/large', { page: 'larger', headers: {} }); const before = [...cache.keys()]; @@ -36,7 +81,7 @@ test('snapshot reports entries, HTML byte sizes and TTL without changing LRU ord }); test('snapshot counts expired entries even when the cache does not serve stale pages', async () => { - const cache = new LRUCache({ max: 2, ttl: 1, allowStale: false }); + const cache = new SsrPageCache({ max: 2, ttl: 1, allowStale: false }); cache.set('/expired', { page: 'x' }); await sleep(5); assert.equal(cache.size, 1); @@ -51,16 +96,21 @@ test('snapshot counts expired entries even when the cache does not serve stale p test('snapshot of a missing cache and of a cache without TTL', () => { assert.deepEqual(snapshotCache('bot', undefined, false), { - name: 'bot', enabled: false, maxEntries: 0, configuredTtlMs: 0, entryCount: 0, staleCount: 0, totalHtmlBytes: 0, entries: [] + name: 'bot', enabled: false, hits: 0, misses: 0, hitRate: null, + maxEntries: 0, configuredTtlMs: 0, entryCount: 0, staleCount: 0, totalHtmlBytes: 0, entries: [] }); - const cache = new LRUCache({ max: 1 }); + const cache = new SsrPageCache({ max: 1 }); cache.set('/forever', { page: 'x' }); assert.equal(snapshotCache('bot', cache, true).entries[0].remainingTtlMs, null); }); test('stats server listens on loopback and answers only GET /app/cache-stats', async () => { - const payload = statsOf([]); - const server = startSsrCacheStatsServer(0, () => payload); + const cache = new SsrPageCache({ max: 2 }); + cache.set('/page', { page: 'html' }); + cache.getForRequest('/page'); + cache.getForRequest('/missing'); + const payload = statsOf([snapshotCache('bot', cache, true)]); + const server = startSsrCacheStatsServer(0, () => statsOf([snapshotCache('bot', cache, true)])); try { await once(server, 'listening'); const address = server.address(); @@ -77,6 +127,8 @@ test('stats server listens on loopback and answers only GET /app/cache-stats', a assert.equal((await fetch(`${base}/app/cache-stats/`)).status, 404); assert.equal((await fetch(`${base}/app/cache-stats`, { method: 'HEAD' })).status, 404); assert.equal((await fetch(`${base}/app/cache-stats`, { method: 'POST' })).status, 404); + assert.equal(cache.hits, 1); + assert.equal(cache.misses, 1); } finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); diff --git a/server.ts b/server.ts index 25432610ec1..1f0ba48c15e 100644 --- a/server.ts +++ b/server.ts @@ -24,7 +24,6 @@ import express from 'express'; import * as ejs from 'ejs'; import * as compression from 'compression'; import expressStaticGzip from 'express-static-gzip'; -import { LRUCache } from 'lru-cache'; import { isbot } from 'isbot'; import { createCertificate } from 'pem'; import { createServer } from 'https'; @@ -64,8 +63,8 @@ import { isDirectLoopbackRequest, snapshotCache, SSR_CACHE_STATS_PATH, - SsrCacheEntry, SsrCacheStats, + SsrPageCache, startSsrCacheStatsServer } from './src/backend/ssr-cache-stats'; @@ -87,10 +86,10 @@ appConfig.themes.forEach(themeConfig => hashedFileMapping.addThemeStyle(themeCon hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode -let botCache: LRUCache; +let botCache: SsrPageCache; // cache of SSR pages for anonymous users. Disabled by default, and only available in production mode -let anonymousCache: LRUCache; +let anonymousCache: SsrPageCache; // extend environment with app config for server extendEnvironmentWithAppConfig(environment, appConfig); @@ -421,7 +420,7 @@ function initCache() { // Initialize a new "least-recently-used" item cache (where least recently used pages are removed first) // See https://www.npmjs.com/package/lru-cache // When enabled, each page defaults to expiring after 1 day (defined in default-app-config.ts) - botCache = new LRUCache( { + botCache = new SsrPageCache( { max: environment.cache.serverSide.botCache.max, ttl: environment.cache.serverSide.botCache.timeToLive, allowStale: environment.cache.serverSide.botCache.allowStale, @@ -433,7 +432,7 @@ function initCache() { // may expire pages more frequently. // When enabled, each page defaults to expiring after 10 seconds (defined in default-app-config.ts) // to minimize anonymous users seeing out-of-date content - anonymousCache = new LRUCache( { + anonymousCache = new SsrPageCache( { max: environment.cache.serverSide.anonymousCache.max, ttl: environment.cache.serverSide.anonymousCache.timeToLive, allowStale: environment.cache.serverSide.anonymousCache.allowStale, @@ -510,12 +509,12 @@ function cacheCheck(req, res, next) { * @param next the next function * @returns cached copy (if found) or undefined (if not found) */ -function checkCacheForRequest(cacheName: string, cache: LRUCache, req, res, next): any { +function checkCacheForRequest(cacheName: string, cache: SsrPageCache, req, res, next): any { // Get the cache key for this request const key = getCacheKey(req); // Check if this page is in our cache - const cachedCopy = cache.get(key); + const cachedCopy = cache.getForRequest(key); if (cachedCopy) { if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); } diff --git a/src/backend/ssr-cache-stats.ts b/src/backend/ssr-cache-stats.ts index f828bc2022a..66d5b5354d4 100644 --- a/src/backend/ssr-cache-stats.ts +++ b/src/backend/ssr-cache-stats.ts @@ -11,6 +11,23 @@ export interface SsrCacheEntry { headers?: Record; } +/** Per-worker page cache with lifetime request counters; maintenance reads are not counted. */ +export class SsrPageCache extends LRUCache { + hits = 0; + misses = 0; + + /** Count the existing lookup once. A stale page returned by allowStale is a hit. */ + getForRequest(key: string): SsrCacheEntry | undefined { + const entry = this.get(key); + if (entry) { + this.hits++; + } else { + this.misses++; + } + return entry; + } +} + export interface SsrCacheEntryStats { key: string; htmlBytes: number; @@ -21,6 +38,10 @@ export interface SsrCacheEntryStats { export interface SsrCacheSnapshot { name: string; enabled: boolean; + hits: number; + misses: number; + /** Hits / (hits + misses), or null before the first request lookup. */ + hitRate: number | null; maxEntries: number; configuredTtlMs: number; entryCount: number; @@ -49,7 +70,7 @@ export const SSR_CACHE_STATS_PATH = '/app/cache-stats'; * `dump()` visits stale entries too (unlike `forEach`, which skips them when `allowStale` is false); * `dump()` and `has()` leave recency and TTL untouched. Byte counting is O(total cached HTML). */ -export function snapshotCache(name: string, cache: LRUCache | undefined, enabled: boolean): SsrCacheSnapshot { +export function snapshotCache(name: string, cache: SsrPageCache | undefined, enabled: boolean): SsrCacheSnapshot { const entries: SsrCacheEntryStats[] = []; if (cache) { for (const [key, { value }] of cache.dump()) { @@ -63,9 +84,14 @@ export function snapshotCache(name: string, cache: LRUCache b.htmlBytes - a.htmlBytes || a.key.localeCompare(b.key)); + const hits = cache ? cache.hits : 0; + const misses = cache ? cache.misses : 0; return { name, enabled, + hits, + misses, + hitRate: hits + misses > 0 ? hits / (hits + misses) : null, maxEntries: cache ? cache.max : 0, configuredTtlMs: cache ? cache.ttl : 0, entryCount: entries.length, From d7b68026319c884ee16f8277500a3088e7fcd617 Mon Sep 17 00:00:00 2001 From: jm Date: Thu, 29 Oct 2026 18:04:28 +0100 Subject: [PATCH 2/2] JCU: keep SSR cache counter changes minimal --- config/config.example.yml | 3 -- scripts/ssr-cache-stats.sh | 11 ----- scripts/test-ssr-cache-stats.js | 74 ++++++--------------------------- server.ts | 25 +++++++---- src/backend/ssr-cache-stats.ts | 35 +++++----------- 5 files changed, 40 insertions(+), 108 deletions(-) delete mode 100755 scripts/ssr-cache-stats.sh diff --git a/config/config.example.yml b/config/config.example.yml index ff1e99f1bee..37ae63052c5 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -108,9 +108,6 @@ cache: debug: false # Lets operators on this machine see what the caches below hold: GET /app/cache-stats (JSON). # Each Node process reports its own cache. Off by default. - # hits/misses count request lookups since process start, including stale pages served as hits. - # hitRate = hits / (hits + misses), or null before any lookups. Bypassed requests are not counted. - # Poll all pm2 workers with: bash scripts/ssr-cache-stats.sh [container] [base-port] [workers] stats: enabled: false # 0 = answer on the UI port, but only to requests made directly from this host: connecting from 127.0.0.1 and diff --git a/scripts/ssr-cache-stats.sh b/scripts/ssr-cache-stats.sh deleted file mode 100755 index 263d09a5f13..00000000000 --- a/scripts/ssr-cache-stats.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# Requires cache.serverSide.stats.enabled and stats.port > 0 in config.prod.yml. -# Usage: bash scripts/ssr-cache-stats.sh [container] [base-port] [workers] -C=${1:-dspace-angular443} -BASE=${2:-4001} -N=${3:-8} - -for ((i = 0; i < N; i++)); do - p=$((BASE + i)) - docker exec "$C" node -e "fetch('http://127.0.0.1:$p/app/cache-stats').then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}).then(d=>console.log('w'+d.instance,'up',d.uptimeSeconds+'s','rss',Math.round(d.memory.rss/1e6)+'MB',d.caches.map(c=>c.name+':'+(c.enabled?'on':'off')+' n='+c.entryCount+' stale='+c.staleCount+' '+Math.round(c.totalHtmlBytes/1024)+'KB/max'+c.maxEntries+' hits='+c.hits+' misses='+c.misses+' hit='+(c.hitRate==null?'n/a':(100*c.hitRate).toFixed(1)+'%')).join(' | '))).catch(e=>{console.error('$p ERR',e.message);process.exitCode=1})" -done diff --git a/scripts/test-ssr-cache-stats.js b/scripts/test-ssr-cache-stats.js index d2a71661603..ceadb6a546f 100644 --- a/scripts/test-ssr-cache-stats.js +++ b/scripts/test-ssr-cache-stats.js @@ -2,13 +2,14 @@ const assert = require('node:assert/strict'); const { once } = require('node:events'); const { test } = require('node:test'); const { setTimeout: sleep } = require('node:timers/promises'); +const { LRUCache } = require('lru-cache'); process.env.TS_NODE_PROJECT = 'tsconfig.ts-node.json'; // tsconfig.json uses moduleResolution 'bundler', which ts-node cannot combine with the commonjs output it needs. process.env.TS_NODE_COMPILER_OPTIONS = JSON.stringify({ module: 'commonjs', moduleResolution: 'node10' }); require('ts-node/register/transpile-only'); const { - SsrPageCache, snapshotCache, startSsrCacheStatsServer, isDirectLoopbackRequest + snapshotCache, startSsrCacheStatsServer, isDirectLoopbackRequest } = require('../src/backend/ssr-cache-stats.ts'); const statsOf = (caches) => ({ @@ -16,59 +17,17 @@ const statsOf = (caches) => ({ memory: { rss: 1, heapUsed: 1 }, caches }); -test('request lookups count hits and misses independently of cache population and stats polling', () => { - const bot = new SsrPageCache({ max: 2 }); - const anonymous = new SsrPageCache({ max: 2 }); - bot.set('/page', { page: 'html' }); - assert.equal(bot.get('/page').page, 'html'); - assert.equal(bot.has('/page'), true); - assert.equal(snapshotCache('bot', bot, true).hitRate, null); - - assert.equal(bot.getForRequest('/page').page, 'html'); - assert.equal(bot.getForRequest('/missing'), undefined); - assert.equal(bot.getForRequest('/page').page, 'html'); - for (let i = 0; i < 2; i++) { - const stats = snapshotCache('bot', bot, true); - assert.equal(stats.hits, 2); - assert.equal(stats.misses, 1); - assert.equal(stats.hitRate, 2 / 3); - } - const other = snapshotCache('anonymous', anonymous, true); - assert.equal(other.hits, 0); - assert.equal(other.misses, 0); - assert.equal(other.hitRate, null); -}); - -test('expired pages count as hits only when served stale', async () => { - for (const allowStale of [true, false]) { - const cache = new SsrPageCache({ max: 2, ttl: 1, allowStale }); - cache.set('/expired', { page: 'html' }); - await sleep(10); - assert.equal(cache.getForRequest('/expired')?.page, allowStale ? 'html' : undefined); - assert.equal(cache.hits, allowStale ? 1 : 0); - assert.equal(cache.misses, allowStale ? 0 : 1); - assert.equal(cache.getForRequest('/expired'), undefined); - assert.equal(cache.misses, allowStale ? 1 : 2); - } -}); - -test('eviction does not reset request counters', () => { - const cache = new SsrPageCache({ max: 1 }); - cache.set('/first', { page: 'first' }); - cache.getForRequest('/first'); - cache.set('/second', { page: 'second' }); - cache.getForRequest('/first'); - assert.equal(cache.hits, 1); - assert.equal(cache.misses, 1); -}); - test('snapshot reports entries, HTML byte sizes and TTL without changing LRU order', () => { - const cache = new SsrPageCache({ max: 3, ttl: 60000 }); + const cache = new LRUCache({ max: 3, ttl: 60000 }); cache.set('/small', { page: 'é', headers: { 'content-type': 'text/html' } }); cache.set('/large', { page: 'larger', headers: {} }); const before = [...cache.keys()]; - const stats = snapshotCache('bot', cache, true); + const stats = snapshotCache('bot', cache, true, { hits: 3, misses: 1 }); + + assert.equal(stats.hits, 3); + assert.equal(stats.misses, 1); + assert.equal(stats.hitRate, 0.75); assert.equal(stats.entryCount, 2); assert.equal(stats.totalHtmlBytes, 8); @@ -81,7 +40,7 @@ test('snapshot reports entries, HTML byte sizes and TTL without changing LRU ord }); test('snapshot counts expired entries even when the cache does not serve stale pages', async () => { - const cache = new SsrPageCache({ max: 2, ttl: 1, allowStale: false }); + const cache = new LRUCache({ max: 2, ttl: 1, allowStale: false }); cache.set('/expired', { page: 'x' }); await sleep(5); assert.equal(cache.size, 1); @@ -96,21 +55,16 @@ test('snapshot counts expired entries even when the cache does not serve stale p test('snapshot of a missing cache and of a cache without TTL', () => { assert.deepEqual(snapshotCache('bot', undefined, false), { - name: 'bot', enabled: false, hits: 0, misses: 0, hitRate: null, - maxEntries: 0, configuredTtlMs: 0, entryCount: 0, staleCount: 0, totalHtmlBytes: 0, entries: [] + name: 'bot', enabled: false, hits: 0, misses: 0, hitRate: null, maxEntries: 0, configuredTtlMs: 0, entryCount: 0, staleCount: 0, totalHtmlBytes: 0, entries: [] }); - const cache = new SsrPageCache({ max: 1 }); + const cache = new LRUCache({ max: 1 }); cache.set('/forever', { page: 'x' }); assert.equal(snapshotCache('bot', cache, true).entries[0].remainingTtlMs, null); }); test('stats server listens on loopback and answers only GET /app/cache-stats', async () => { - const cache = new SsrPageCache({ max: 2 }); - cache.set('/page', { page: 'html' }); - cache.getForRequest('/page'); - cache.getForRequest('/missing'); - const payload = statsOf([snapshotCache('bot', cache, true)]); - const server = startSsrCacheStatsServer(0, () => statsOf([snapshotCache('bot', cache, true)])); + const payload = statsOf([]); + const server = startSsrCacheStatsServer(0, () => payload); try { await once(server, 'listening'); const address = server.address(); @@ -127,8 +81,6 @@ test('stats server listens on loopback and answers only GET /app/cache-stats', a assert.equal((await fetch(`${base}/app/cache-stats/`)).status, 404); assert.equal((await fetch(`${base}/app/cache-stats`, { method: 'HEAD' })).status, 404); assert.equal((await fetch(`${base}/app/cache-stats`, { method: 'POST' })).status, 404); - assert.equal(cache.hits, 1); - assert.equal(cache.misses, 1); } finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); diff --git a/server.ts b/server.ts index 1f0ba48c15e..47e20ae5423 100644 --- a/server.ts +++ b/server.ts @@ -24,6 +24,7 @@ import express from 'express'; import * as ejs from 'ejs'; import * as compression from 'compression'; import expressStaticGzip from 'express-static-gzip'; +import { LRUCache } from 'lru-cache'; import { isbot } from 'isbot'; import { createCertificate } from 'pem'; import { createServer } from 'https'; @@ -63,8 +64,8 @@ import { isDirectLoopbackRequest, snapshotCache, SSR_CACHE_STATS_PATH, + SsrCacheEntry, SsrCacheStats, - SsrPageCache, startSsrCacheStatsServer } from './src/backend/ssr-cache-stats'; @@ -86,10 +87,14 @@ appConfig.themes.forEach(themeConfig => hashedFileMapping.addThemeStyle(themeCon hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode -let botCache: SsrPageCache; +let botCache: LRUCache; // cache of SSR pages for anonymous users. Disabled by default, and only available in production mode -let anonymousCache: SsrPageCache; +let anonymousCache: LRUCache; +const cacheCounters = { + bot: { hits: 0, misses: 0 }, + anonymous: { hits: 0, misses: 0 }, +}; // extend environment with app config for server extendEnvironmentWithAppConfig(environment, appConfig); @@ -420,7 +425,7 @@ function initCache() { // Initialize a new "least-recently-used" item cache (where least recently used pages are removed first) // See https://www.npmjs.com/package/lru-cache // When enabled, each page defaults to expiring after 1 day (defined in default-app-config.ts) - botCache = new SsrPageCache( { + botCache = new LRUCache( { max: environment.cache.serverSide.botCache.max, ttl: environment.cache.serverSide.botCache.timeToLive, allowStale: environment.cache.serverSide.botCache.allowStale, @@ -432,7 +437,7 @@ function initCache() { // may expire pages more frequently. // When enabled, each page defaults to expiring after 10 seconds (defined in default-app-config.ts) // to minimize anonymous users seeing out-of-date content - anonymousCache = new SsrPageCache( { + anonymousCache = new LRUCache( { max: environment.cache.serverSide.anonymousCache.max, ttl: environment.cache.serverSide.anonymousCache.timeToLive, allowStale: environment.cache.serverSide.anonymousCache.allowStale, @@ -509,13 +514,14 @@ function cacheCheck(req, res, next) { * @param next the next function * @returns cached copy (if found) or undefined (if not found) */ -function checkCacheForRequest(cacheName: string, cache: SsrPageCache, req, res, next): any { +function checkCacheForRequest(cacheName: 'bot' | 'anonymous', cache: LRUCache, req, res, next): any { // Get the cache key for this request const key = getCacheKey(req); // Check if this page is in our cache - const cachedCopy = cache.getForRequest(key); + const cachedCopy = cache.get(key); if (cachedCopy) { + cacheCounters[cacheName].hits++; if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); } // Check if cached copy is expired (If expired, the key will now be gone from cache) @@ -528,6 +534,7 @@ function checkCacheForRequest(cacheName: string, cache: SsrPageCache, req, res, serverSideRender(req, res, next, false); } } else { + cacheCounters[cacheName].misses++; if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); } } @@ -681,8 +688,8 @@ function collectCacheStats(): SsrCacheStats { uptimeSeconds: Math.round(process.uptime()), memory: { rss: memory.rss, heapUsed: memory.heapUsed }, caches: [ - snapshotCache('bot', botCache, !!(environment.production && botCacheEnabled())), - snapshotCache('anonymous', anonymousCache, !!(environment.production && anonymousCacheEnabled())) + snapshotCache('bot', botCache, !!(environment.production && botCacheEnabled()), cacheCounters.bot), + snapshotCache('anonymous', anonymousCache, !!(environment.production && anonymousCacheEnabled()), cacheCounters.anonymous) ] }; } diff --git a/src/backend/ssr-cache-stats.ts b/src/backend/ssr-cache-stats.ts index 66d5b5354d4..443a3e3a108 100644 --- a/src/backend/ssr-cache-stats.ts +++ b/src/backend/ssr-cache-stats.ts @@ -11,23 +11,6 @@ export interface SsrCacheEntry { headers?: Record; } -/** Per-worker page cache with lifetime request counters; maintenance reads are not counted. */ -export class SsrPageCache extends LRUCache { - hits = 0; - misses = 0; - - /** Count the existing lookup once. A stale page returned by allowStale is a hit. */ - getForRequest(key: string): SsrCacheEntry | undefined { - const entry = this.get(key); - if (entry) { - this.hits++; - } else { - this.misses++; - } - return entry; - } -} - export interface SsrCacheEntryStats { key: string; htmlBytes: number; @@ -35,12 +18,16 @@ export interface SsrCacheEntryStats { stale: boolean; } -export interface SsrCacheSnapshot { - name: string; - enabled: boolean; +/** Request lookups since worker start; stale pages served count as hits. */ +export interface SsrCacheCounters { hits: number; misses: number; - /** Hits / (hits + misses), or null before the first request lookup. */ +} + +export interface SsrCacheSnapshot extends SsrCacheCounters { + name: string; + enabled: boolean; + /** Hits / (hits + misses), or null before the first lookup. */ hitRate: number | null; maxEntries: number; configuredTtlMs: number; @@ -70,7 +57,7 @@ export const SSR_CACHE_STATS_PATH = '/app/cache-stats'; * `dump()` visits stale entries too (unlike `forEach`, which skips them when `allowStale` is false); * `dump()` and `has()` leave recency and TTL untouched. Byte counting is O(total cached HTML). */ -export function snapshotCache(name: string, cache: SsrPageCache | undefined, enabled: boolean): SsrCacheSnapshot { +export function snapshotCache(name: string, cache: LRUCache | undefined, enabled: boolean, counters?: SsrCacheCounters): SsrCacheSnapshot { const entries: SsrCacheEntryStats[] = []; if (cache) { for (const [key, { value }] of cache.dump()) { @@ -84,8 +71,8 @@ export function snapshotCache(name: string, cache: SsrPageCache | undefined, ena } } entries.sort((a, b) => b.htmlBytes - a.htmlBytes || a.key.localeCompare(b.key)); - const hits = cache ? cache.hits : 0; - const misses = cache ? cache.misses : 0; + const hits = counters?.hits ?? 0; + const misses = counters?.misses ?? 0; return { name, enabled,