Skip to content

Commit a8d280c

Browse files
authored
Add ability to detect when MemoryCacheStore reaches max size (#4224)
* Initial impl * modified failing test * modified a comment * moved tests in memory-cache-store-size.js to memory-cache-store-tests.js
1 parent 59940c8 commit a8d280c

File tree

4 files changed

+181
-5
lines changed

4 files changed

+181
-5
lines changed

docs/docs/api/CacheStore.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,28 @@ The `MemoryCacheStore` stores the responses in-memory.
1313

1414
**Options**
1515

16+
- `maxSize` - The maximum total size in bytes of all stored responses. Default `Infinity`.
1617
- `maxCount` - The maximum amount of responses to store. Default `Infinity`.
17-
- `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.
18+
- `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`.
19+
20+
### Getters
21+
22+
#### `MemoryCacheStore.size`
23+
24+
Returns the current total size in bytes of all stored responses.
25+
26+
### Methods
27+
28+
#### `MemoryCacheStore.isFull()`
29+
30+
Returns a boolean indicating whether the cache has reached its maximum size or count.
31+
32+
### Events
33+
34+
#### `'maxSizeExceeded'`
35+
36+
Emitted when the cache exceeds its maximum size or count limits. The event payload contains `size`, `maxSize`, `count`, and `maxCount` properties.
37+
1838

1939
### `SqliteCacheStore`
2040

@@ -26,7 +46,7 @@ The `SqliteCacheStore` is only exposed if the `node:sqlite` api is present.
2646

2747
- `location` - The location of the SQLite database to use. Default `:memory:`.
2848
- `maxCount` - The maximum number of entries to store in the database. Default `Infinity`.
29-
- `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`.
49+
- `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`.
3050

3151
## Defining a Custom Cache Store
3252

lib/cache/memory-cache-store.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const { Writable } = require('node:stream')
4+
const { EventEmitter } = require('node:events')
45
const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
56

67
/**
@@ -12,20 +13,23 @@ const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
1213

1314
/**
1415
* @implements {CacheStore}
16+
* @extends {EventEmitter}
1517
*/
16-
class MemoryCacheStore {
18+
class MemoryCacheStore extends EventEmitter {
1719
#maxCount = Infinity
1820
#maxSize = Infinity
1921
#maxEntrySize = Infinity
2022

2123
#size = 0
2224
#count = 0
2325
#entries = new Map()
26+
#hasEmittedMaxSizeEvent = false
2427

2528
/**
2629
* @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
2730
*/
2831
constructor (opts) {
32+
super()
2933
if (opts) {
3034
if (typeof opts !== 'object') {
3135
throw new TypeError('MemoryCacheStore options must be an object')
@@ -66,6 +70,22 @@ class MemoryCacheStore {
6670
}
6771
}
6872

73+
/**
74+
* Get the current size of the cache in bytes
75+
* @returns {number} The current size of the cache in bytes
76+
*/
77+
get size () {
78+
return this.#size
79+
}
80+
81+
/**
82+
* Check if the cache is full (either max size or max count reached)
83+
* @returns {boolean} True if the cache is full, false otherwise
84+
*/
85+
isFull () {
86+
return this.#size >= this.#maxSize || this.#count >= this.#maxCount
87+
}
88+
6989
/**
7090
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
7191
* @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
@@ -144,7 +164,20 @@ class MemoryCacheStore {
144164

145165
store.#size += entry.size
146166

167+
// Check if cache is full and emit event if needed
147168
if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
169+
// Emit maxSizeExceeded event if we haven't already
170+
if (!store.#hasEmittedMaxSizeEvent) {
171+
store.emit('maxSizeExceeded', {
172+
size: store.#size,
173+
maxSize: store.#maxSize,
174+
count: store.#count,
175+
maxCount: store.#maxCount
176+
})
177+
store.#hasEmittedMaxSizeEvent = true
178+
}
179+
180+
// Perform eviction
148181
for (const [key, entries] of store.#entries) {
149182
for (const entry of entries.splice(0, entries.length / 2)) {
150183
store.#size -= entry.size
@@ -154,6 +187,11 @@ class MemoryCacheStore {
154187
store.#entries.delete(key)
155188
}
156189
}
190+
191+
// Reset the event flag after eviction
192+
if (store.#size < store.#maxSize && store.#count < store.#maxCount) {
193+
store.#hasEmittedMaxSizeEvent = false
194+
}
157195
}
158196

159197
callback(null)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,124 @@
11
'use strict'
22

3+
const { test } = require('node:test')
4+
const { equal } = require('node:assert')
35
const MemoryCacheStore = require('../../lib/cache/memory-cache-store')
46
const { cacheStoreTests } = require('./cache-store-test-utils.js')
57

68
cacheStoreTests(MemoryCacheStore)
9+
10+
test('size getter returns correct total size', async () => {
11+
const store = new MemoryCacheStore()
12+
const testData = 'test data'
13+
14+
equal(store.size, 0, 'Initial size should be 0')
15+
16+
const writeStream = store.createWriteStream(
17+
{ origin: 'test', path: '/', method: 'GET' },
18+
{
19+
statusCode: 200,
20+
statusMessage: 'OK',
21+
headers: {},
22+
cachedAt: Date.now(),
23+
staleAt: Date.now() + 1000,
24+
deleteAt: Date.now() + 2000
25+
}
26+
)
27+
28+
writeStream.write(testData)
29+
writeStream.end()
30+
31+
equal(store.size, testData.length, 'Size should match written data length')
32+
})
33+
34+
test('isFull returns false when under limits', () => {
35+
const store = new MemoryCacheStore({
36+
maxSize: 1000,
37+
maxCount: 10
38+
})
39+
40+
equal(store.isFull(), false, 'Should not be full when empty')
41+
})
42+
43+
test('isFull returns true when maxSize reached', async () => {
44+
const maxSize = 10
45+
const store = new MemoryCacheStore({ maxSize })
46+
const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize
47+
48+
const writeStream = store.createWriteStream(
49+
{ origin: 'test', path: '/', method: 'GET' },
50+
{
51+
statusCode: 200,
52+
statusMessage: 'OK',
53+
headers: {},
54+
cachedAt: Date.now(),
55+
staleAt: Date.now() + 1000,
56+
deleteAt: Date.now() + 2000
57+
}
58+
)
59+
60+
writeStream.write(testData)
61+
writeStream.end()
62+
63+
equal(store.isFull(), true, 'Should be full when maxSize exceeded')
64+
})
65+
66+
test('isFull returns true when maxCount reached', async () => {
67+
const maxCount = 2
68+
const store = new MemoryCacheStore({ maxCount })
69+
70+
// Add maxCount + 1 entries
71+
for (let i = 0; i <= maxCount; i++) {
72+
const writeStream = store.createWriteStream(
73+
{ origin: 'test', path: `/${i}`, method: 'GET' },
74+
{
75+
statusCode: 200,
76+
statusMessage: 'OK',
77+
headers: {},
78+
cachedAt: Date.now(),
79+
staleAt: Date.now() + 1000,
80+
deleteAt: Date.now() + 2000
81+
}
82+
)
83+
writeStream.end('test')
84+
}
85+
86+
equal(store.isFull(), true, 'Should be full when maxCount exceeded')
87+
})
88+
89+
test('emits maxSizeExceeded event when limits exceeded', async () => {
90+
const maxSize = 10
91+
const store = new MemoryCacheStore({ maxSize })
92+
93+
let eventFired = false
94+
let eventPayload = null
95+
96+
store.on('maxSizeExceeded', (payload) => {
97+
eventFired = true
98+
eventPayload = payload
99+
})
100+
101+
const testData = 'x'.repeat(maxSize + 1) // Exceed maxSize
102+
103+
const writeStream = store.createWriteStream(
104+
{ origin: 'test', path: '/', method: 'GET' },
105+
{
106+
statusCode: 200,
107+
statusMessage: 'OK',
108+
headers: {},
109+
cachedAt: Date.now(),
110+
staleAt: Date.now() + 1000,
111+
deleteAt: Date.now() + 2000
112+
}
113+
)
114+
115+
writeStream.write(testData)
116+
writeStream.end()
117+
118+
equal(eventFired, true, 'maxSizeExceeded event should fire')
119+
equal(typeof eventPayload, 'object', 'Event should have payload')
120+
equal(typeof eventPayload.size, 'number', 'Payload should have size')
121+
equal(typeof eventPayload.maxSize, 'number', 'Payload should have maxSize')
122+
equal(typeof eventPayload.count, 'number', 'Payload should have count')
123+
equal(typeof eventPayload.maxCount, 'number', 'Payload should have maxCount')
124+
})

test/fixtures/wpt/xhr/formdata/append.any.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
assert_equals(create_formdata(['key', null], ['key', 'value1']).get('key'), "null");
2020
}, 'testFormDataAppendNull2');
2121
test(function() {
22-
var before = new Date(new Date().getTime() - 2000); // two seconds ago, in case there's clock drift
22+
var before = Date.now() - 2000; // Two seconds ago,(in case there's clock drift) using timestamp number
2323
var fd = create_formdata(['key', new Blob(), 'blank.txt']).get('key');
2424
assert_equals(fd.name, "blank.txt");
2525
assert_equals(fd.type, "");
2626
assert_equals(fd.size, 0);
2727
assert_greater_than_equal(fd.lastModified, before);
28-
assert_less_than_equal(fd.lastModified, new Date());
28+
assert_less_than_equal(fd.lastModified, Date.now());
2929
}, 'testFormDataAppendEmptyBlob');
3030

3131
function create_formdata() {

0 commit comments

Comments
 (0)