Skip to content

Commit 70f7314

Browse files
authored
fix: memory store (#3834)
* fix: memory store Simplify and fix memory leak * fixup * fixup * fixup * fixup * fixup * fixup * fixup * fixup * fixup * fixup * fixup * fixup
1 parent 1779440 commit 70f7314

File tree

4 files changed

+117
-313
lines changed

4 files changed

+117
-313
lines changed

lib/cache/memory-cache-store.js

Lines changed: 84 additions & 230 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,23 @@
33
const { Writable } = require('node:stream')
44

55
/**
6+
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheKey} CacheKey
7+
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheValue} CacheValue
68
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
9+
* @typedef {import('../../types/cache-interceptor.d.ts').default.GetResult} GetResult
10+
*/
11+
12+
/**
713
* @implements {CacheStore}
8-
*
9-
* @typedef {{
10-
* locked: boolean
11-
* opts: import('../../types/cache-interceptor.d.ts').default.CachedResponse
12-
* body?: Buffer[]
13-
* }} MemoryStoreValue
1414
*/
1515
class MemoryCacheStore {
1616
#maxCount = Infinity
17-
17+
#maxSize = Infinity
1818
#maxEntrySize = Infinity
1919

20-
#entryCount = 0
21-
22-
/**
23-
* @type {Map<string, Map<string, MemoryStoreValue[]>>}
24-
*/
25-
#data = new Map()
20+
#size = 0
21+
#count = 0
22+
#entries = new Map()
2623

2724
/**
2825
* @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
@@ -44,6 +41,17 @@ class MemoryCacheStore {
4441
this.#maxCount = opts.maxCount
4542
}
4643

44+
if (opts.maxSize !== undefined) {
45+
if (
46+
typeof opts.maxSize !== 'number' ||
47+
!Number.isInteger(opts.maxSize) ||
48+
opts.maxSize < 0
49+
) {
50+
throw new TypeError('MemoryCacheStore options.maxSize must be a non-negative integer')
51+
}
52+
this.#maxSize = opts.maxSize
53+
}
54+
4755
if (opts.maxEntrySize !== undefined) {
4856
if (
4957
typeof opts.maxEntrySize !== 'number' ||
@@ -57,269 +65,115 @@ class MemoryCacheStore {
5765
}
5866
}
5967

60-
get isFull () {
61-
return this.#entryCount >= this.#maxCount
62-
}
63-
6468
/**
65-
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
69+
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
6670
* @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
6771
*/
6872
get (key) {
6973
if (typeof key !== 'object') {
7074
throw new TypeError(`expected key to be object, got ${typeof key}`)
7175
}
7276

73-
const values = this.#getValuesForRequest(key, false)
74-
if (!values) {
75-
return undefined
76-
}
77-
78-
const value = this.#findValue(key, values)
79-
80-
if (!value || value.locked) {
81-
return undefined
82-
}
77+
const topLevelKey = `${key.origin}:${key.path}`
8378

84-
return { ...value.opts, body: value.body }
79+
const now = Date.now()
80+
const entry = this.#entries.get(topLevelKey)?.find((entry) => (
81+
entry.deleteAt > now &&
82+
entry.method === key.method &&
83+
(entry.vary == null || Object.keys(entry.vary).every(headerName => entry.vary[headerName] === key.headers?.[headerName]))
84+
))
85+
86+
return entry == null
87+
? undefined
88+
: {
89+
statusMessage: entry.statusMessage,
90+
statusCode: entry.statusCode,
91+
rawHeaders: entry.rawHeaders,
92+
body: entry.body,
93+
cachedAt: entry.cachedAt,
94+
staleAt: entry.staleAt,
95+
deleteAt: entry.deleteAt
96+
}
8597
}
8698

8799
/**
88100
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
89-
* @param {import('../../types/cache-interceptor.d.ts').default.CachedResponse} opts
101+
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} val
90102
* @returns {Writable | undefined}
91103
*/
92-
createWriteStream (key, opts) {
104+
createWriteStream (key, val) {
93105
if (typeof key !== 'object') {
94106
throw new TypeError(`expected key to be object, got ${typeof key}`)
95107
}
96-
if (typeof opts !== 'object') {
97-
throw new TypeError(`expected value to be object, got ${typeof opts}`)
98-
}
99-
100-
if (this.isFull) {
101-
return undefined
108+
if (typeof val !== 'object') {
109+
throw new TypeError(`expected value to be object, got ${typeof val}`)
102110
}
103111

104-
const values = this.#getValuesForRequest(key, true)
105-
106-
/**
107-
* @type {(MemoryStoreValue & { index: number }) | undefined}
108-
*/
109-
let value = this.#findValue(key, values)
110-
let valueIndex = value?.index
111-
if (!value) {
112-
// The value doesn't already exist, meaning we haven't cached this
113-
// response before. Let's assign it a value and insert it into our data
114-
// property.
115-
116-
if (this.isFull) {
117-
// Or not, we don't have space to add another response
118-
return undefined
119-
}
120-
121-
this.#entryCount++
122-
123-
value = {
124-
locked: true,
125-
opts
126-
}
127-
128-
// We want to sort our responses in decending order by their deleteAt
129-
// timestamps so that deleting expired responses is faster
130-
if (
131-
values.length === 0 ||
132-
opts.deleteAt < values[values.length - 1].deleteAt
133-
) {
134-
// Our value is either the only response for this path or our deleteAt
135-
// time is sooner than all the other responses
136-
values.push(value)
137-
valueIndex = values.length - 1
138-
} else if (opts.deleteAt >= values[0].deleteAt) {
139-
// Our deleteAt is later than everyone elses
140-
values.unshift(value)
141-
valueIndex = 0
142-
} else {
143-
// We're neither in the front or the end, let's just binary search to
144-
// find our stop we need to be in
145-
let startIndex = 0
146-
let endIndex = values.length
147-
while (true) {
148-
if (startIndex === endIndex) {
149-
values.splice(startIndex, 0, value)
150-
break
151-
}
152-
153-
const middleIndex = Math.floor((startIndex + endIndex) / 2)
154-
const middleValue = values[middleIndex]
155-
if (opts.deleteAt === middleIndex) {
156-
values.splice(middleIndex, 0, value)
157-
valueIndex = middleIndex
158-
break
159-
} else if (opts.deleteAt > middleValue.opts.deleteAt) {
160-
endIndex = middleIndex
161-
continue
162-
} else {
163-
startIndex = middleIndex
164-
continue
165-
}
166-
}
167-
}
168-
} else {
169-
// Check if there's already another request writing to the value or
170-
// a request reading from it
171-
if (value.locked) {
172-
return undefined
173-
}
174-
175-
// Empty it so we can overwrite it
176-
value.body = []
177-
}
112+
const topLevelKey = `${key.origin}:${key.path}`
178113

179-
let currentSize = 0
180-
/**
181-
* @type {Buffer[] | null}
182-
*/
183-
let body = key.method !== 'HEAD' ? [] : null
184-
const maxEntrySize = this.#maxEntrySize
114+
const store = this
115+
const entry = { ...key, ...val, body: [], size: 0 }
185116

186-
const writable = new Writable({
117+
return new Writable({
187118
write (chunk, encoding, callback) {
188-
if (key.method === 'HEAD') {
189-
throw new Error('HEAD request shouldn\'t have a body')
190-
}
191-
192-
if (!body) {
193-
return callback()
194-
}
195-
196119
if (typeof chunk === 'string') {
197120
chunk = Buffer.from(chunk, encoding)
198121
}
199122

200-
currentSize += chunk.byteLength
123+
entry.size += chunk.byteLength
201124

202-
if (currentSize >= maxEntrySize) {
203-
body = null
204-
this.end()
205-
shiftAtIndex(values, valueIndex)
206-
return callback()
125+
if (entry.size >= store.#maxEntrySize) {
126+
this.destroy()
127+
} else {
128+
entry.body.push(chunk)
207129
}
208130

209-
body.push(chunk)
210-
callback()
131+
callback(null)
211132
},
212133
final (callback) {
213-
value.locked = false
214-
if (body !== null) {
215-
value.body = body
134+
let entries = store.#entries.get(topLevelKey)
135+
if (!entries) {
136+
entries = []
137+
store.#entries.set(topLevelKey, entries)
138+
}
139+
entries.push(entry)
140+
141+
store.#size += entry.size
142+
store.#count += 1
143+
144+
if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
145+
for (const [key, entries] of store.#entries) {
146+
for (const entry of entries.splice(0, entries.length / 2)) {
147+
store.#size -= entry.size
148+
store.#count -= 1
149+
}
150+
if (entries.length === 0) {
151+
store.#entries.delete(key)
152+
}
153+
}
216154
}
217155

218-
callback()
156+
callback(null)
219157
}
220158
})
221-
222-
return writable
223159
}
224160

225161
/**
226-
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
162+
* @param {CacheKey} key
227163
*/
228164
delete (key) {
229-
this.#data.delete(`${key.origin}:${key.path}`)
230-
}
231-
232-
/**
233-
* Gets all of the requests of the same origin, path, and method. Does not
234-
* take the `vary` property into account.
235-
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
236-
* @param {boolean} [makeIfDoesntExist=false]
237-
* @returns {MemoryStoreValue[] | undefined}
238-
*/
239-
#getValuesForRequest (key, makeIfDoesntExist) {
240-
// https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
241-
const topLevelKey = `${key.origin}:${key.path}`
242-
let cachedPaths = this.#data.get(topLevelKey)
243-
if (!cachedPaths) {
244-
if (!makeIfDoesntExist) {
245-
return undefined
246-
}
247-
248-
cachedPaths = new Map()
249-
this.#data.set(topLevelKey, cachedPaths)
250-
}
251-
252-
let value = cachedPaths.get(key.method)
253-
if (!value && makeIfDoesntExist) {
254-
value = []
255-
cachedPaths.set(key.method, value)
165+
if (typeof key !== 'object') {
166+
throw new TypeError(`expected key to be object, got ${typeof key}`)
256167
}
257168

258-
return value
259-
}
260-
261-
/**
262-
* Given a list of values of a certain request, this decides the best value
263-
* to respond with.
264-
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
265-
* @param {MemoryStoreValue[]} values
266-
* @returns {(MemoryStoreValue & { index: number }) | undefined}
267-
*/
268-
#findValue (req, values) {
269-
/**
270-
* @type {MemoryStoreValue | undefined}
271-
*/
272-
let value
273-
const now = Date.now()
274-
for (let i = values.length - 1; i >= 0; i--) {
275-
const current = values[i]
276-
const currentCacheValue = current.opts
277-
if (now >= currentCacheValue.deleteAt) {
278-
// We've reached expired values, let's delete them
279-
this.#entryCount -= values.length - i
280-
values.length = i
281-
break
282-
}
283-
284-
let matches = true
285-
286-
if (currentCacheValue.vary) {
287-
if (!req.headers) {
288-
matches = false
289-
break
290-
}
291-
292-
for (const key in currentCacheValue.vary) {
293-
if (currentCacheValue.vary[key] !== req.headers[key]) {
294-
matches = false
295-
break
296-
}
297-
}
298-
}
169+
const topLevelKey = `${key.origin}:${key.path}`
299170

300-
if (matches) {
301-
value = {
302-
...current,
303-
index: i
304-
}
305-
break
306-
}
171+
for (const entry of this.#entries.get(topLevelKey) ?? []) {
172+
this.#size -= entry.size
173+
this.#count -= 1
307174
}
308-
309-
return value
310-
}
311-
}
312-
313-
/**
314-
* @param {any[]} array Array to modify
315-
* @param {number} idx Index to delete
316-
*/
317-
function shiftAtIndex (array, idx) {
318-
for (let i = idx + 1; idx < array.length; i++) {
319-
array[i - 1] = array[i]
175+
this.#entries.delete(topLevelKey)
320176
}
321-
322-
array.length--
323177
}
324178

325179
module.exports = MemoryCacheStore

0 commit comments

Comments
 (0)