From 822fb66af0666d57f2e3b620d458e54de656d2f7 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:52:07 +0100 Subject: [PATCH] perf(cache): skip expiry scans while cached entries are fresh (#86) --- backend/src/cache/boundedTtlMap.test.ts | 35 ++++++++++++++++++++++ backend/src/cache/boundedTtlMap.ts | 12 +++++++- backend/src/cache/policyRegistry.ts | 6 ++++ backend/src/tools/benchmarkBoundedCache.ts | 29 ++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 backend/src/tools/benchmarkBoundedCache.ts diff --git a/backend/src/cache/boundedTtlMap.test.ts b/backend/src/cache/boundedTtlMap.test.ts index d18b996..896dd4f 100644 --- a/backend/src/cache/boundedTtlMap.test.ts +++ b/backend/src/cache/boundedTtlMap.test.ts @@ -60,3 +60,38 @@ test('bounded cache overwrite accounts weight once and reports hit/miss/eviction }); cache.shutdown(); }); + +test('sweeps respect replacement deadlines, access order, and backwards clock changes', () => { + let now = 100; + const cache = new BoundedTtlMap({ + maxEntries: 4, maxWeight: 100, ttlMs: 100, now: () => now, + }); + try { + cache.set('replaced', 'old'); + now = 150; + cache.set('replaced', 'new'); + now = 50; + cache.set('earlier', 'value'); + cache.get('replaced'); // LRU order must not determine expiry order. + now = 150; + cache.sweep(); + assert.equal(cache.has('earlier'), false); + assert.equal(cache.get('replaced'), 'new'); + now = 200; // The overwritten value's original expiry is harmless. + cache.sweep(); + assert.equal(cache.size, 1); + now = 250; + cache.sweep(); + assert.equal(cache.size, 0); + assert.equal(cache.weight(), 0); + assert.equal(cache.metrics().expiries, 2); + cache.clear(); + now = 0; + cache.set('after-clear', 'value'); + now = 100; + cache.sweep(); + assert.equal(cache.size, 0); + } finally { + cache.shutdown(); + } +}); diff --git a/backend/src/cache/boundedTtlMap.ts b/backend/src/cache/boundedTtlMap.ts index 32b8df4..0db8a7e 100644 --- a/backend/src/cache/boundedTtlMap.ts +++ b/backend/src/cache/boundedTtlMap.ts @@ -27,6 +27,9 @@ export class BoundedTtlMap extends Map { private readonly insertedAt = new Map(); private readonly weights = new Map(); private totalWeight = 0; + // A lower bound on the next expiry. Deletions may leave it conservatively + // early, but writes need not walk the whole cache while every entry is fresh. + private nextExpiryAt = Number.POSITIVE_INFINITY; private readonly timer: NodeJS.Timeout; private hits = 0; private misses = 0; @@ -90,7 +93,9 @@ export class BoundedTtlMap extends Map { this.sweep(); this.delete(key); super.set(key, value); - this.insertedAt.set(key, this.options.now?.() ?? Date.now()); + const insertedAt = this.options.now?.() ?? Date.now(); + this.insertedAt.set(key, insertedAt); + this.nextExpiryAt = Math.min(this.nextExpiryAt, insertedAt + this.options.ttlMs); this.weights.set(key, weight); this.totalWeight += weight; while (this.size > this.options.maxEntries || this.totalWeight > this.options.maxWeight) { @@ -122,16 +127,21 @@ export class BoundedTtlMap extends Map { this.insertedAt.clear(); this.weights.clear(); this.totalWeight = 0; + this.nextExpiryAt = Number.POSITIVE_INFINITY; this.recordMetric('cleared'); this.syncMetricGauges(); } sweep(now = this.options.now?.() ?? Date.now()): void { + if (now < this.nextExpiryAt) return; + this.nextExpiryAt = Number.POSITIVE_INFINITY; for (const [key, timestamp] of this.insertedAt) { if (now - timestamp >= this.options.ttlMs) { this.expiries += 1; this.recordMetric('expired'); this.delete(key); + } else { + this.nextExpiryAt = Math.min(this.nextExpiryAt, timestamp + this.options.ttlMs); } } } diff --git a/backend/src/cache/policyRegistry.ts b/backend/src/cache/policyRegistry.ts index 83adc37..7bfeaf8 100644 --- a/backend/src/cache/policyRegistry.ts +++ b/backend/src/cache/policyRegistry.ts @@ -177,4 +177,10 @@ export const CACHE_POLICY_REGISTRY: readonly CachePolicyRecord[] = Object.freeze scope: 'network + hours + limit', invalidation: 'TTL', negativeCaching: 'completed rows only', singleFlight: 'not applicable', }, + { + source: 'src/tools/benchmarkBoundedCache.ts#cache', + disposition: 'request-local-exclusion', + scope: 'one standalone benchmark sample', invalidation: 'shutdown after each sample', + negativeCaching: 'none', singleFlight: 'synchronous standalone tool', + }, ]); diff --git a/backend/src/tools/benchmarkBoundedCache.ts b/backend/src/tools/benchmarkBoundedCache.ts new file mode 100644 index 0000000..b289d5f --- /dev/null +++ b/backend/src/tools/benchmarkBoundedCache.ts @@ -0,0 +1,29 @@ +/** Run with tsx. Benchmark in an isolated checkout; no services are contacted. */ +import { BoundedTtlMap } from '../cache/boundedTtlMap.js'; + +function measure(entries: number) { + const cache = new BoundedTtlMap({ + maxEntries: entries, maxWeight: 16 * 1024 * 1024, + ttlMs: 3_600_000, weightOf: () => 1, now: () => 0, + }); + try { + const start = performance.now(); + for (let key = 0; key < entries; key += 1) cache.set(key, key); + const fillMs = performance.now() - start; + const updateStart = performance.now(); + for (let key = 0; key < 2_000; key += 1) cache.set(key, key + 1); + return { + entries, + fillMs: Number(fillMs.toFixed(2)), + update2000Ms: Number((performance.now() - updateStart).toFixed(2)), + retainedEntries: cache.size, + }; + } finally { + cache.shutdown(); + } +} + +measure(2_000); +for (const entries of [4_096, 20_000, 50_000]) { + console.log(JSON.stringify(measure(entries))); +}