From eb4a19a090fc426e5422e8ec6f2314d6a1433211 Mon Sep 17 00:00:00 2001 From: Taylor McKinnon Date: Tue, 21 Oct 2025 09:46:38 -0700 Subject: [PATCH] Add counter and cached config storage --- lib/api/apiUtils/rateLimit/cache.js | 74 ++++++++++++++ tests/unit/api/apiUtils/rateLimit/cache.js | 110 +++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 lib/api/apiUtils/rateLimit/cache.js create mode 100644 tests/unit/api/apiUtils/rateLimit/cache.js diff --git a/lib/api/apiUtils/rateLimit/cache.js b/lib/api/apiUtils/rateLimit/cache.js new file mode 100644 index 0000000000..23edb2f66c --- /dev/null +++ b/lib/api/apiUtils/rateLimit/cache.js @@ -0,0 +1,74 @@ +const counters = new Map(); + +const configCache = new Map(); + +function setCounter(key, value) { + // Make sure that the Map remains in order + // Counters expiring soonest will be first during iteration. + counters.delete(key); + counters.set(key, value); +} + +function getCounter(key) { + return counters.get(key); +} + +function expireCounters(now) { + const toRemove = []; + for (const [key, value] of counters.entries()) { + if (value <= now) { + toRemove.push(key); + } + } + + for (const key of toRemove) { + counters.delete(key); + } +} + +function setCachedConfig(key, limitConfig, ttl) { + const expiry = Date.now() + ttl; + configCache.set(key, { expiry, config: limitConfig }); +} + +function getCachedConfig(key) { + const value = configCache.get(key); + if (value === undefined) { + return undefined; + } + + const { expiry, config } = value; + if (expiry <= Date.now()) { + configCache.delete(key); + return undefined; + } + + return config; +} + +function expireCachedConfigs(now) { + const toRemove = []; + for (const [key, { expiry }] of configCache.entries()) { + if (expiry <= now) { + toRemove.push(key); + } + } + + for (const key of toRemove) { + configCache.delete(key); + } +} + +module.exports = { + setCounter, + getCounter, + expireCounters, + setCachedConfig, + getCachedConfig, + expireCachedConfigs, + + // Do not access directly + // Used only for tests + counters, + configCache, +}; diff --git a/tests/unit/api/apiUtils/rateLimit/cache.js b/tests/unit/api/apiUtils/rateLimit/cache.js new file mode 100644 index 0000000000..5b97c7d01c --- /dev/null +++ b/tests/unit/api/apiUtils/rateLimit/cache.js @@ -0,0 +1,110 @@ +const assert = require('assert'); +const sinon = require('sinon'); + +const constants = require('../../../../../constants'); +const { + counters, + configCache, + getCounter, + setCounter, + expireCounters, + getCachedConfig, + setCachedConfig, + expireCachedConfigs, +} = require('../../../../../lib/api/apiUtils/rateLimit/cache'); + +describe('test counter storage', () => { + it('setCounter() should set a counter', () => { + setCounter('foo', 10); + assert.strictEqual(counters.get('foo'), 10); + }); + + it('getCounter() should get a counter', () => { + setCounter('foo', 10); + assert.strictEqual(getCounter('foo'), 10); + }); + + it('should maintain order when updating a counter', () => { + setCounter('foo', 10); + setCounter('bar', 20); + setCounter('foo', 30); + + const items = Array.from(counters.entries()); + assert.deepStrictEqual(items, [ + ['bar', 20], + ['foo', 30], + ]); + }); + + it('should expire counters less than or equal to the given timestamp', () => { + const now = Date.now(); + const past = now - 100; + const future = now + 100; + setCounter('past', past); + setCounter('present', now); + setCounter('future', future); + expireCounters(now); + assert.strictEqual(getCounter('past'), undefined); + assert.strictEqual(getCounter('present'), undefined); + assert.strictEqual(getCounter('future'), future); + }); +}); + +describe('test limit config cache storage', () => { + const now = Date.now(); + + let clock; + before(() => { + clock = sinon.useFakeTimers(now); + }); + + after(() => { + clock.restore(); + }); + + it('should add config to cache', () => { + setCachedConfig('foo', 10, constants.rateLimitDefaultConfigCacheTTL); + assert.deepStrictEqual( + configCache.get('foo'), + { + expiry: now + constants.rateLimitDefaultConfigCacheTTL, + config: 10, + } + ); + }); + + it('should get a non expired config', () => { + setCachedConfig('foo', 10, constants.rateLimitDefaultConfigCacheTTL); + assert.strictEqual(getCachedConfig('foo'), 10); + }); + + it('should return undefined and delete the key for an expired config', () => { + configCache.set('foo', { + expiry: now - 10000, + config: 10, + }); + assert.strictEqual(getCachedConfig('foo'), undefined); + }); + + it('should expire configs less than or equal to the given timestamp', () => { + configCache.set('past', { + expiry: now - 10000, + config: 10, + }); + configCache.set('present', { + expiry: now, + config: 10, + }); + configCache.set('future', { + expiry: now + 10000, + config: 10, + }); + expireCachedConfigs(now); + assert.strictEqual(configCache.get('past'), undefined); + assert.strictEqual(configCache.get('present'), undefined); + assert.deepStrictEqual(configCache.get('future'), { + expiry: now + 10000, + config: 10, + }); + }); +});