From ec35b291eee9c185e1ce03f3900eebb296f84b8e Mon Sep 17 00:00:00 2001 From: you Date: Sun, 29 Mar 2026 14:06:29 +0000 Subject: [PATCH] fix: cache hit rate excludes stale hits + debounce bulk-health invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cache bugs fixed: 1. Hit rate formula excluded stale hits — reported rate was artificially low because stale-while-revalidate responses (which ARE cache hits from the caller's perspective) were not counted. Changed formula from hits/(hits+misses) to (hits+staleHits)/(hits+staleHits+misses). 2. Bulk-health cache invalidated on every advert packet — in a mesh with dozens of nodes advertising every few seconds, this caused the expensive bulk-health query to be recomputed on nearly every request, defeating the cache entirely. Switched to 30s debounced invalidation via debouncedInvalidateBulkHealth(). Added regression test for hit rate formula in test-server-routes.js. --- server.js | 13 ++++++++++--- test-server-routes.js | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index 4962c213..0a3c652d 100644 --- a/server.js +++ b/server.js @@ -207,6 +207,13 @@ class TTLCache { if (key.startsWith(prefix)) this.store.delete(key); } } + debouncedInvalidateBulkHealth() { + if (this._bulkHealthTimer) return; + this._bulkHealthTimer = setTimeout(() => { + this._bulkHealthTimer = null; + this.invalidate('bulk-health'); + }, 30000); + } debouncedInvalidateAll() { if (this._debounceTimer) return; this._debounceTimer = setTimeout(() => { @@ -410,7 +417,7 @@ app.get('/api/perf', (req, res) => { avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0, endpoints: Object.fromEntries(sorted), slowQueries: perfStats.slowQueries.slice(-20), - cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0 }, + cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + cache.misses) * 1000) / 10 : 0 }, packetStore: pktStore.getStats(), sqlite: (() => { try { @@ -519,7 +526,7 @@ app.get('/api/health', (req, res) => { misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, - hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0, + hitRate: cache.hits + cache.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + cache.misses) * 1000) / 10 : 0, }, websocket: { clients: wsClients, @@ -723,7 +730,7 @@ for (const source of mqttSources) { // Invalidate this node's caches on advert cache.invalidate('node:' + p.pubKey); cache.invalidate('health:' + p.pubKey); - cache.invalidate('bulk-health'); + cache.debouncedInvalidateBulkHealth(); // Cross-reference: if this node's pubkey matches an existing observer, backfill observer name if (p.name && p.pubKey) { diff --git a/test-server-routes.js b/test-server-routes.js index 724ed1c0..d1f448d5 100644 --- a/test-server-routes.js +++ b/test-server-routes.js @@ -1254,6 +1254,24 @@ seedTestData(); lastPathSeenMap.delete(liveNode); }); + // ── Cache hit rate includes stale hits ── + await t('Cache hitRate includes staleHits in formula', async () => { + cache.clear(); + cache.hits = 0; + cache.misses = 0; + cache.staleHits = 0; + // Simulate: 3 hits, 2 stale hits, 5 misses => rate = (3+2)/(3+2+5) = 50% + cache.hits = 3; + cache.staleHits = 2; + cache.misses = 5; + const r = await request(app).get('/api/health').expect(200); + assert(r.body.cache.hitRate === 50, 'hitRate should be (hits+staleHits)/(hits+staleHits+misses) = 50%, got ' + r.body.cache.hitRate); + // Reset + cache.hits = 0; + cache.misses = 0; + cache.staleHits = 0; + }); + // ── Summary ── console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`); if (failed > 0) process.exit(1);