current commit
260924.12 · fix(cache): evict least recently used entries in MemoryCacheStore (#5869)
'use strict'

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

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

/**
 * @implements {CacheStore}
 * @extends {EventEmitter}
 */
class MemoryCacheStore extends EventEmitter {
  #maxCount = 1024
  #maxSize = 104857600 // 100MB
  #maxEntrySize = 5242880 // 5MB

  #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')
      }

      if (opts.maxCount !== undefined) {
        if (
          typeof opts.maxCount !== 'number' ||
          !Number.isInteger(opts.maxCount) ||
          opts.maxCount < 0
        ) {
          throw new TypeError('MemoryCacheStore options.maxCount must be a non-negative integer')
        }
        this.#maxCount = opts.maxCount
      }

      if (opts.maxSize !== undefined) {
        if (
          typeof opts.maxSize !== 'number' ||
          !Number.isInteger(opts.maxSize) ||
          opts.maxSize < 0
        ) {
          throw new TypeError('MemoryCacheStore options.maxSize must be a non-negative integer')
        }
        this.#maxSize = opts.maxSize
      }

      if (opts.maxEntrySize !== undefined) {
        if (
          typeof opts.maxEntrySize !== 'number' ||
          !Number.isInteger(opts.maxEntrySize) ||
          opts.maxEntrySize < 0
        ) {
          throw new TypeError('MemoryCacheStore options.maxEntrySize must be a non-negative integer')
        }
        this.#maxEntrySize = opts.maxEntrySize
      }
    }
  }

  /**
   * 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} key
   * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
   */
  get (key) {
    assertCacheKey(key)

    const topLevelKey = `${key.origin}:${key.path}`

    const now = Date.now()
    const entries = this.#entries.get(topLevelKey)

    const entry = entries ? findEntry(key, entries, now) : null

    if (entry != null) {
      this.#entries.delete(topLevelKey)
      this.#entries.set(topLevelKey, entries)
    }

    return entry == null
      ? undefined
      : {
          statusMessage: entry.statusMessage,
          statusCode: entry.statusCode,
          headers: entry.headers,
          body: entry.body,
          vary: entry.vary ? entry.vary : undefined,
          etag: entry.etag,
          cacheControlDirectives: entry.cacheControlDirectives,
          cachedAt: entry.cachedAt,
          staleAt: entry.staleAt,
          deleteAt: entry.deleteAt
        }
  }

  /**
   * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
   * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} val
   * @returns {Writable | undefined}
   */
  createWriteStream (key, val) {
    assertCacheKey(key)
    assertCacheValue(val)

    const topLevelKey = `${key.origin}:${key.path}`

    const store = this
    const entry = { ...key, ...val, body: [], size: 0 }

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

        entry.size += chunk.byteLength

        if (entry.size > store.#maxEntrySize) {
          this.destroy()
        } else {
          entry.body.push(chunk)
        }

        callback(null)
      },
      final (callback) {
        let entries = store.#entries.get(topLevelKey)
        if (entries) {
          store.#entries.delete(topLevelKey)
        } else {
          entries = []
        }
        store.#entries.set(topLevelKey, entries)
        const previousEntry = findEntry(key, entries, -Infinity)
        if (previousEntry) {
          const index = entries.indexOf(previousEntry)
          entries.splice(index, 1, entry)
          store.#size -= previousEntry.size
        } else {
          entries.push(entry)
          store.#count += 1
        }

        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
          }

          store.#evict(entry)

          // Reset the event flag after eviction
          if (store.#size < store.#maxSize && store.#count < store.#maxCount) {
            store.#hasEmittedMaxSizeEvent = false
          }
        }

        callback(null)
      }
    })
  }

  #evict (keep) {
    const targetSize = this.#maxSize / 2
    const targetCount = this.#maxCount / 2

    for (const [key, entries] of this.#entries) {
      for (let i = 0; i < entries.length && (this.#size > targetSize || this.#count > targetCount);) {
        const entry = entries[i]
        if (entry === keep) {
          i++
          continue
        }
        entries.splice(i, 1)
        this.#size -= entry.size
        this.#count -= 1
      }

      if (entries.length === 0) {
        this.#entries.delete(key)
      }

      if (this.#size <= targetSize && this.#count <= targetCount) {
        return
      }
    }

    if (this.#size > this.#maxSize || this.#count > this.#maxCount) {
      const topLevelKey = `${keep.origin}:${keep.path}`
      const entries = this.#entries.get(topLevelKey)
      entries.splice(entries.indexOf(keep), 1)
      this.#size -= keep.size
      this.#count -= 1
      if (entries.length === 0) {
        this.#entries.delete(topLevelKey)
      }
    }
  }

  /**
   * @param {CacheKey} key
   */
  delete (key) {
    if (typeof key !== 'object') {
      throw new TypeError(`expected key to be object, got ${typeof key}`)
    }

    const topLevelKey = `${key.origin}:${key.path}`

    for (const entry of this.#entries.get(topLevelKey) ?? []) {
      this.#size -= entry.size
      this.#count -= 1
    }
    this.#entries.delete(topLevelKey)
  }
}

function findEntry (key, entries, now) {
  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i]
    if (
      entry.deleteAt > now &&
      entry.method === key.method &&
      varyMatches(key, entry)
    ) {
      return entry
    }
  }
}

function varyMatches (key, entry) {
  if (entry.vary == null) {
    return true
  }

  for (const headerName in entry.vary) {
    if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) {
      return false
    }
  }

  return true
}

/**
 * @param {string|string[]|null|undefined} lhs
 * @param {string|string[]|null|undefined} rhs
 * @returns {boolean}
 */
function headerValueEquals (lhs, rhs) {
  if (lhs == null && rhs == null) {
    return true
  }

  if ((lhs == null && rhs != null) ||
      (lhs != null && rhs == null)) {
    return false
  }

  if (Array.isArray(lhs) && Array.isArray(rhs)) {
    if (lhs.length !== rhs.length) {
      return false
    }

    for (let i = 0; i < lhs.length; i++) {
      if (lhs[i] !== rhs[i]) {
        return false
      }
    }

    return true
  }

  return lhs === rhs
}

module.exports = MemoryCacheStore