Skip to content

fix: memory store #3834

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 75 additions & 191 deletions lib/cache/memory-cache-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,24 @@
const { Writable } = require('node:stream')

/**
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheKey} CacheKey
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
* @typedef {import('../../types/cache-interceptor.d.ts').default.CachedResponse} CachedResponse
* @typedef {import('../../types/cache-interceptor.d.ts').default.GetResult} GetResult
*/

/**
* @implements {CacheStore}
*
* @typedef {{
* locked: boolean
* opts: import('../../types/cache-interceptor.d.ts').default.CachedResponse
* body?: Buffer[]
* }} MemoryStoreValue
*/
class MemoryCacheStore {
#maxCount = Infinity

#maxEntrySize = Infinity

#entryCount = 0

/**
* @type {Map<string, Map<string, MemoryStoreValue[]>>}
* @type {Map<string, Map<string, GetResult[]>>}
*/
#data = new Map()
#map = new Map()
#arr = []

/**
* @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
Expand Down Expand Up @@ -58,30 +56,16 @@ class MemoryCacheStore {
}

get isFull () {
return this.#entryCount >= this.#maxCount
return this.#arr.length >= this.#maxCount
}

/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
*/
get (key) {
if (typeof key !== 'object') {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}

const values = this.#getValuesForRequest(key, false)
if (!values) {
return undefined
}

const value = this.#findValue(key, values)

if (!value || value.locked) {
return undefined
}

return { ...value.opts, body: value.body }
const values = this.#getValuesForRequest(key)
return findValue(key, values)
}

/**
Expand All @@ -98,228 +82,128 @@ class MemoryCacheStore {
}

if (this.isFull) {
return undefined
this.#prune()
}

const values = this.#getValuesForRequest(key, true)

/**
* @type {(MemoryStoreValue & { index: number }) | undefined}
*/
let value = this.#findValue(key, values)
let valueIndex = value?.index
if (!value) {
// The value doesn't already exist, meaning we haven't cached this
// response before. Let's assign it a value and insert it into our data
// property.

if (this.isFull) {
// Or not, we don't have space to add another response
return undefined
}

this.#entryCount++

value = {
locked: true,
opts
}

// We want to sort our responses in decending order by their deleteAt
// timestamps so that deleting expired responses is faster
if (
values.length === 0 ||
opts.deleteAt < values[values.length - 1].deleteAt
) {
// Our value is either the only response for this path or our deleteAt
// time is sooner than all the other responses
values.push(value)
valueIndex = values.length - 1
} else if (opts.deleteAt >= values[0].deleteAt) {
// Our deleteAt is later than everyone elses
values.unshift(value)
valueIndex = 0
} else {
// We're neither in the front or the end, let's just binary search to
// find our stop we need to be in
let startIndex = 0
let endIndex = values.length
while (true) {
if (startIndex === endIndex) {
values.splice(startIndex, 0, value)
break
}

const middleIndex = Math.floor((startIndex + endIndex) / 2)
const middleValue = values[middleIndex]
if (opts.deleteAt === middleIndex) {
values.splice(middleIndex, 0, value)
valueIndex = middleIndex
break
} else if (opts.deleteAt > middleValue.opts.deleteAt) {
endIndex = middleIndex
continue
} else {
startIndex = middleIndex
continue
}
}
}
} else {
// Check if there's already another request writing to the value or
// a request reading from it
if (value.locked) {
return undefined
}

// Empty it so we can overwrite it
value.body = []
if (this.isFull) {
return undefined
}

let currentSize = 0
/**
* @type {Buffer[] | null}
*/
let body = key.method !== 'HEAD' ? [] : null
const maxEntrySize = this.#maxEntrySize

const writable = new Writable({
write (chunk, encoding, callback) {
if (key.method === 'HEAD') {
throw new Error('HEAD request shouldn\'t have a body')
}

if (!body) {
return callback()
}
const store = this
const body = []

return new Writable({
write (chunk, encoding, callback) {
if (typeof chunk === 'string') {
chunk = Buffer.from(chunk, encoding)
}

currentSize += chunk.byteLength

if (currentSize >= maxEntrySize) {
body = null
this.end()
shiftAtIndex(values, valueIndex)
return callback()
if (currentSize >= store.#maxEntrySize) {
this.destroy()
} else {
body.push(chunk)
}

body.push(chunk)
callback()
callback(null)
},
final (callback) {
value.locked = false
if (body !== null) {
value.body = body
const values = store.#getValuesForRequest(key)

let value = findValue(key, values)
if (!value) {
value = { ...opts, body }
store.#arr.push(value)
values.push(value)
} else {
Object.assign(value, opts, { body })
}

callback()
callback(null)
}
})

return writable
}

/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @param {CacheKey} key
*/
delete (key) {
this.#data.delete(`${key.origin}:${key.path}`)
this.#map.delete(`${key.origin}:${key.path}`)
}

/**
* Gets all of the requests of the same origin, path, and method. Does not
* take the `vary` property into account.
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @param {boolean} [makeIfDoesntExist=false]
* @returns {MemoryStoreValue[] | undefined}
* @param {CacheKey} key
* @returns {GetResult[]}
*/
#getValuesForRequest (key, makeIfDoesntExist) {
#getValuesForRequest (key) {
if (typeof key !== 'object') {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}

// https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
const topLevelKey = `${key.origin}:${key.path}`
let cachedPaths = this.#data.get(topLevelKey)
let cachedPaths = this.#map.get(topLevelKey)
if (!cachedPaths) {
if (!makeIfDoesntExist) {
return undefined
}

cachedPaths = new Map()
this.#data.set(topLevelKey, cachedPaths)
this.#map.set(topLevelKey, cachedPaths)
}

let value = cachedPaths.get(key.method)
if (!value && makeIfDoesntExist) {
if (!value) {
value = []
cachedPaths.set(key.method, value)
}

return value
}

/**
* Given a list of values of a certain request, this decides the best value
* to respond with.
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
* @param {MemoryStoreValue[]} values
* @returns {(MemoryStoreValue & { index: number }) | undefined}
*/
#findValue (req, values) {
/**
* @type {MemoryStoreValue | undefined}
*/
let value
const now = Date.now()
for (let i = values.length - 1; i >= 0; i--) {
const current = values[i]
const currentCacheValue = current.opts
if (now >= currentCacheValue.deleteAt) {
// We've reached expired values, let's delete them
this.#entryCount -= values.length - i
values.length = i
break
}

let matches = true
#prune () {
// TODO (perf): This could be implemented more efficiently...

if (currentCacheValue.vary) {
if (!req.headers) {
matches = false
break
}
const count = Math.max(0, this.#arr.length - this.#maxCount / 2)
for (const value of this.#arr.splice(0, count)) {
value.body = null
}

for (const key in currentCacheValue.vary) {
if (currentCacheValue.vary[key] !== req.headers[key]) {
matches = false
break
for (const [key, cachedPaths] of this.#map) {
for (const [method, prev] of cachedPaths) {
const next = prev.filter(({ body }) => body == null)
if (next.length === 0) {
cachedPaths.delete(method)
if (cachedPaths.size === 0) {
this.#map.delete(key)
}
} else if (next.length !== prev.length) {
cachedPaths.set(method, next)
}
}

if (matches) {
value = {
...current,
index: i
}
break
}
}

return value
}
}

/**
* @param {any[]} array Array to modify
* @param {number} idx Index to delete
* Given a list of values of a certain request, this decides the best value
* to respond with.
* @param {CacheKey} key
* @param {GetResult[] | undefined } values
* @returns {(GetResult) | undefined}
*/
function shiftAtIndex (array, idx) {
for (let i = idx + 1; idx < array.length; i++) {
array[i - 1] = array[i]
function findValue (key, values) {
if (typeof key !== 'object') {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}

array.length--
const now = Date.now()
return values?.find(({ deleteAt, vary, body }) => (
body != null &&
deleteAt > now &&
(!vary || Object.keys(vary).every(headerName => vary[headerName] === key.headers?.[headerName]))
))
}

module.exports = MemoryCacheStore
Loading
Loading