From 70c3361d375c6bf5fe831ce8e944bc7959d4f1a8 Mon Sep 17 00:00:00 2001 From: Felix Ezama-Vaughan Date: Wed, 14 May 2025 20:18:41 -0600 Subject: [PATCH 1/4] Initial impl --- docs/docs/api/CacheStore.md | 24 ++++++- lib/cache/memory-cache-store.js | 40 ++++++++++- test/memory-cache-store-size.js | 123 ++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 test/memory-cache-store-size.js diff --git a/docs/docs/api/CacheStore.md b/docs/docs/api/CacheStore.md index 7cd19e08786..0f3b3eebc7f 100644 --- a/docs/docs/api/CacheStore.md +++ b/docs/docs/api/CacheStore.md @@ -13,8 +13,28 @@ The `MemoryCacheStore` stores the responses in-memory. **Options** +- `maxSize` - The maximum total size in bytes of all stored responses. Default `Infinity`. - `maxCount` - The maximum amount of responses to store. Default `Infinity`. -- `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached. +- `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`. + +### Getters + +#### `MemoryCacheStore.size` + +Returns the current total size in bytes of all stored responses. + +### Methods + +#### `MemoryCacheStore.isFull()` + +Returns a boolean indicating whether the cache has reached its maximum size or count. + +### Events + +#### `'maxSizeExceeded'` + +Emitted when the cache exceeds its maximum size or count limits. The event payload contains `size`, `maxSize`, `count`, and `maxCount` properties. + ### `SqliteCacheStore` @@ -26,7 +46,7 @@ The `SqliteCacheStore` is only exposed if the `node:sqlite` api is present. - `location` - The location of the SQLite database to use. Default `:memory:`. - `maxCount` - The maximum number of entries to store in the database. Default `Infinity`. -- `maxEntrySize` - The maximum size in bytes that a resposne's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`. +- `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`. ## Defining a Custom Cache Store diff --git a/lib/cache/memory-cache-store.js b/lib/cache/memory-cache-store.js index 4640092b5f5..2fb3dfabd19 100644 --- a/lib/cache/memory-cache-store.js +++ b/lib/cache/memory-cache-store.js @@ -1,6 +1,7 @@ 'use strict' const { Writable } = require('node:stream') +const { EventEmitter } = require('node:events') const { assertCacheKey, assertCacheValue } = require('../util/cache.js') /** @@ -12,8 +13,9 @@ const { assertCacheKey, assertCacheValue } = require('../util/cache.js') /** * @implements {CacheStore} + * @extends {EventEmitter} */ -class MemoryCacheStore { +class MemoryCacheStore extends EventEmitter { #maxCount = Infinity #maxSize = Infinity #maxEntrySize = Infinity @@ -21,11 +23,13 @@ class MemoryCacheStore { #size = 0 #count = 0 #entries = new Map() + #hasEmittedMaxSizeEvent = false /** * @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts] */ constructor (opts) { + super() if (opts) { if (typeof opts !== 'object') { throw new TypeError('MemoryCacheStore options must be an object') @@ -66,6 +70,22 @@ class MemoryCacheStore { } } + /** + * Get the current size of the cache in bytes + * @returns {number} The current size of the cache in bytes + */ + get size () { + return this.#size + } + + /** + * Check if the cache is full (either max size or max count reached) + * @returns {boolean} True if the cache is full, false otherwise + */ + isFull () { + return this.#size >= this.#maxSize || this.#count >= this.#maxCount + } + /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} @@ -144,7 +164,20 @@ class MemoryCacheStore { store.#size += entry.size + // Check if cache is full and emit event if needed if (store.#size > store.#maxSize || store.#count > store.#maxCount) { + // Emit maxSizeExceeded event if we haven't already + if (!store.#hasEmittedMaxSizeEvent) { + store.emit('maxSizeExceeded', { + size: store.#size, + maxSize: store.#maxSize, + count: store.#count, + maxCount: store.#maxCount + }) + store.#hasEmittedMaxSizeEvent = true + } + + // Perform eviction for (const [key, entries] of store.#entries) { for (const entry of entries.splice(0, entries.length / 2)) { store.#size -= entry.size @@ -154,6 +187,11 @@ class MemoryCacheStore { store.#entries.delete(key) } } + + // Reset the event flag after eviction + if (store.#size < store.#maxSize && store.#count < store.#maxCount) { + store.#hasEmittedMaxSizeEvent = false + } } callback(null) diff --git a/test/memory-cache-store-size.js b/test/memory-cache-store-size.js new file mode 100644 index 00000000000..c3995dd9709 --- /dev/null +++ b/test/memory-cache-store-size.js @@ -0,0 +1,123 @@ +'use strict' + +const { describe, test } = require('node:test') +const { equal } = require('node:assert') +const MemoryCacheStore = require('../lib/cache/memory-cache-store') + +describe('Cache Store', () => { + test('size getter returns correct total size', async () => { + const store = new MemoryCacheStore() + const testData = 'test data' + + equal(store.size, 0, 'Initial size should be 0') + + const writeStream = store.createWriteStream( + { origin: 'test', path: '/', method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + + writeStream.write(testData) + writeStream.end() + + equal(store.size, testData.length, 'Size should match written data length') + }) + + test('isFull returns false when under limits', () => { + const store = new MemoryCacheStore({ + maxSize: 1000, + maxCount: 10 + }) + + equal(store.isFull(), false, 'Should not be full when empty') + }) + + test('isFull returns true when maxSize reached', async () => { + const maxSize = 10 + const store = new MemoryCacheStore({ maxSize }) + const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize + + const writeStream = store.createWriteStream( + { origin: 'test', path: '/', method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + + writeStream.write(testData) + writeStream.end() + + equal(store.isFull(), true, 'Should be full when maxSize exceeded') + }) + + test('isFull returns true when maxCount reached', async () => { + const maxCount = 2 + const store = new MemoryCacheStore({ maxCount }) + + // Add maxCount + 1 entries + for (let i = 0; i <= maxCount; i++) { + const writeStream = store.createWriteStream( + { origin: 'test', path: `/${i}`, method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + writeStream.end('test') + } + + equal(store.isFull(), true, 'Should be full when maxCount exceeded') + }) + + test('emits maxSizeExceeded event when limits exceeded', async () => { + const maxSize = 10 + const store = new MemoryCacheStore({ maxSize }) + + let eventFired = false + let eventPayload = null + + store.on('maxSizeExceeded', (payload) => { + eventFired = true + eventPayload = payload + }) + + const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize + + const writeStream = store.createWriteStream( + { origin: 'test', path: '/', method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + + writeStream.write(testData) + writeStream.end() + + equal(eventFired, true, 'maxSizeExceeded event should fire') + equal(typeof eventPayload, 'object', 'Event should have payload') + equal(typeof eventPayload.size, 'number', 'Payload should have size') + equal(typeof eventPayload.maxSize, 'number', 'Payload should have maxSize') + equal(typeof eventPayload.count, 'number', 'Payload should have count') + equal(typeof eventPayload.maxCount, 'number', 'Payload should have maxCount') + }) +}) From 0454a437162654820dcbd6a0ef9495546e5ef664 Mon Sep 17 00:00:00 2001 From: Felix Ezama-Vaughan Date: Thu, 15 May 2025 23:23:37 -0600 Subject: [PATCH 2/4] modified failing test --- test/fixtures/wpt/xhr/formdata/append.any.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/fixtures/wpt/xhr/formdata/append.any.js b/test/fixtures/wpt/xhr/formdata/append.any.js index fb365618d20..fba71ca86bb 100644 --- a/test/fixtures/wpt/xhr/formdata/append.any.js +++ b/test/fixtures/wpt/xhr/formdata/append.any.js @@ -19,13 +19,13 @@ assert_equals(create_formdata(['key', null], ['key', 'value1']).get('key'), "null"); }, 'testFormDataAppendNull2'); test(function() { - var before = new Date(new Date().getTime() - 2000); // two seconds ago, in case there's clock drift + var before = Date.now() - 2000; // Two seconds ago, using timestamp number var fd = create_formdata(['key', new Blob(), 'blank.txt']).get('key'); assert_equals(fd.name, "blank.txt"); assert_equals(fd.type, ""); assert_equals(fd.size, 0); assert_greater_than_equal(fd.lastModified, before); - assert_less_than_equal(fd.lastModified, new Date()); + assert_less_than_equal(fd.lastModified, Date.now()); }, 'testFormDataAppendEmptyBlob'); function create_formdata() { From 210488166bae8597481fdfe17a01dc173c5e1166 Mon Sep 17 00:00:00 2001 From: Felix Ezama-Vaughan Date: Thu, 15 May 2025 23:35:23 -0600 Subject: [PATCH 3/4] modified a comment --- test/fixtures/wpt/xhr/formdata/append.any.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/wpt/xhr/formdata/append.any.js b/test/fixtures/wpt/xhr/formdata/append.any.js index fba71ca86bb..7c79ac90329 100644 --- a/test/fixtures/wpt/xhr/formdata/append.any.js +++ b/test/fixtures/wpt/xhr/formdata/append.any.js @@ -19,7 +19,7 @@ assert_equals(create_formdata(['key', null], ['key', 'value1']).get('key'), "null"); }, 'testFormDataAppendNull2'); test(function() { - var before = Date.now() - 2000; // Two seconds ago, using timestamp number + var before = Date.now() - 2000; // Two seconds ago,(in case there's clock drift) using timestamp number var fd = create_formdata(['key', new Blob(), 'blank.txt']).get('key'); assert_equals(fd.name, "blank.txt"); assert_equals(fd.type, ""); From 98c7711db836bfe1bfb19b094dd80bc542430764 Mon Sep 17 00:00:00 2001 From: Felix Ezama-Vaughan Date: Sun, 18 May 2025 19:29:18 -0600 Subject: [PATCH 4/4] moved tests in memory-cache-store-size.js to memory-cache-store-tests.js --- .../memory-cache-store-tests.js | 118 +++++++++++++++++ test/memory-cache-store-size.js | 123 ------------------ 2 files changed, 118 insertions(+), 123 deletions(-) delete mode 100644 test/memory-cache-store-size.js diff --git a/test/cache-interceptor/memory-cache-store-tests.js b/test/cache-interceptor/memory-cache-store-tests.js index 3f2a7d89e63..740cbdc31ce 100644 --- a/test/cache-interceptor/memory-cache-store-tests.js +++ b/test/cache-interceptor/memory-cache-store-tests.js @@ -1,6 +1,124 @@ 'use strict' +const { test } = require('node:test') +const { equal } = require('node:assert') const MemoryCacheStore = require('../../lib/cache/memory-cache-store') const { cacheStoreTests } = require('./cache-store-test-utils.js') cacheStoreTests(MemoryCacheStore) + +test('size getter returns correct total size', async () => { + const store = new MemoryCacheStore() + const testData = 'test data' + + equal(store.size, 0, 'Initial size should be 0') + + const writeStream = store.createWriteStream( + { origin: 'test', path: '/', method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + + writeStream.write(testData) + writeStream.end() + + equal(store.size, testData.length, 'Size should match written data length') +}) + +test('isFull returns false when under limits', () => { + const store = new MemoryCacheStore({ + maxSize: 1000, + maxCount: 10 + }) + + equal(store.isFull(), false, 'Should not be full when empty') +}) + +test('isFull returns true when maxSize reached', async () => { + const maxSize = 10 + const store = new MemoryCacheStore({ maxSize }) + const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize + + const writeStream = store.createWriteStream( + { origin: 'test', path: '/', method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + + writeStream.write(testData) + writeStream.end() + + equal(store.isFull(), true, 'Should be full when maxSize exceeded') +}) + +test('isFull returns true when maxCount reached', async () => { + const maxCount = 2 + const store = new MemoryCacheStore({ maxCount }) + + // Add maxCount + 1 entries + for (let i = 0; i <= maxCount; i++) { + const writeStream = store.createWriteStream( + { origin: 'test', path: `/${i}`, method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + writeStream.end('test') + } + + equal(store.isFull(), true, 'Should be full when maxCount exceeded') +}) + +test('emits maxSizeExceeded event when limits exceeded', async () => { + const maxSize = 10 + const store = new MemoryCacheStore({ maxSize }) + + let eventFired = false + let eventPayload = null + + store.on('maxSizeExceeded', (payload) => { + eventFired = true + eventPayload = payload + }) + + const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize + + const writeStream = store.createWriteStream( + { origin: 'test', path: '/', method: 'GET' }, + { + statusCode: 200, + statusMessage: 'OK', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 2000 + } + ) + + writeStream.write(testData) + writeStream.end() + + equal(eventFired, true, 'maxSizeExceeded event should fire') + equal(typeof eventPayload, 'object', 'Event should have payload') + equal(typeof eventPayload.size, 'number', 'Payload should have size') + equal(typeof eventPayload.maxSize, 'number', 'Payload should have maxSize') + equal(typeof eventPayload.count, 'number', 'Payload should have count') + equal(typeof eventPayload.maxCount, 'number', 'Payload should have maxCount') +}) diff --git a/test/memory-cache-store-size.js b/test/memory-cache-store-size.js deleted file mode 100644 index c3995dd9709..00000000000 --- a/test/memory-cache-store-size.js +++ /dev/null @@ -1,123 +0,0 @@ -'use strict' - -const { describe, test } = require('node:test') -const { equal } = require('node:assert') -const MemoryCacheStore = require('../lib/cache/memory-cache-store') - -describe('Cache Store', () => { - test('size getter returns correct total size', async () => { - const store = new MemoryCacheStore() - const testData = 'test data' - - equal(store.size, 0, 'Initial size should be 0') - - const writeStream = store.createWriteStream( - { origin: 'test', path: '/', method: 'GET' }, - { - statusCode: 200, - statusMessage: 'OK', - headers: {}, - cachedAt: Date.now(), - staleAt: Date.now() + 1000, - deleteAt: Date.now() + 2000 - } - ) - - writeStream.write(testData) - writeStream.end() - - equal(store.size, testData.length, 'Size should match written data length') - }) - - test('isFull returns false when under limits', () => { - const store = new MemoryCacheStore({ - maxSize: 1000, - maxCount: 10 - }) - - equal(store.isFull(), false, 'Should not be full when empty') - }) - - test('isFull returns true when maxSize reached', async () => { - const maxSize = 10 - const store = new MemoryCacheStore({ maxSize }) - const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize - - const writeStream = store.createWriteStream( - { origin: 'test', path: '/', method: 'GET' }, - { - statusCode: 200, - statusMessage: 'OK', - headers: {}, - cachedAt: Date.now(), - staleAt: Date.now() + 1000, - deleteAt: Date.now() + 2000 - } - ) - - writeStream.write(testData) - writeStream.end() - - equal(store.isFull(), true, 'Should be full when maxSize exceeded') - }) - - test('isFull returns true when maxCount reached', async () => { - const maxCount = 2 - const store = new MemoryCacheStore({ maxCount }) - - // Add maxCount + 1 entries - for (let i = 0; i <= maxCount; i++) { - const writeStream = store.createWriteStream( - { origin: 'test', path: `/${i}`, method: 'GET' }, - { - statusCode: 200, - statusMessage: 'OK', - headers: {}, - cachedAt: Date.now(), - staleAt: Date.now() + 1000, - deleteAt: Date.now() + 2000 - } - ) - writeStream.end('test') - } - - equal(store.isFull(), true, 'Should be full when maxCount exceeded') - }) - - test('emits maxSizeExceeded event when limits exceeded', async () => { - const maxSize = 10 - const store = new MemoryCacheStore({ maxSize }) - - let eventFired = false - let eventPayload = null - - store.on('maxSizeExceeded', (payload) => { - eventFired = true - eventPayload = payload - }) - - const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize - - const writeStream = store.createWriteStream( - { origin: 'test', path: '/', method: 'GET' }, - { - statusCode: 200, - statusMessage: 'OK', - headers: {}, - cachedAt: Date.now(), - staleAt: Date.now() + 1000, - deleteAt: Date.now() + 2000 - } - ) - - writeStream.write(testData) - writeStream.end() - - equal(eventFired, true, 'maxSizeExceeded event should fire') - equal(typeof eventPayload, 'object', 'Event should have payload') - equal(typeof eventPayload.size, 'number', 'Payload should have size') - equal(typeof eventPayload.maxSize, 'number', 'Payload should have maxSize') - equal(typeof eventPayload.count, 'number', 'Payload should have count') - equal(typeof eventPayload.maxCount, 'number', 'Payload should have maxCount') - }) -})