All files / src/common memoize.ts

100% Statements 28/28
100% Branches 8/8
100% Functions 6/6
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88  4x     4x 4x 4x                         4x             20024x       12x 12x   12x 20024x   20024x           20024x 20024x 20024x         20024x   20024x 19x       1x 1x         20024x   20024x       4x       20024x 20024x     20024x         20024x 20016x 2x        
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { assertWithLog } from "./errors";
 
type MemoizeCache<T> = { promise: Promise<T>; lastSet: number };
const EXPECTED_MAX_CACHE_ENTRIES_PER_FN = 100_000;
const EXPECTED_MAX_CACHE_KEY_LENGTH_PER_FN = 10_000;
const CLEAN_EVERY_N_ITERATION_DEFAULT = 1;
 
type MemoizeArgs<F extends (...args: unknown[]) => Promise<unknown>> = {
  functionToMemoize: F;
  ttl: number;
  cleanEveryNIteration?: number;
  cacheKeyBuilder?: (...args: Parameters<F>) => string | Promise<string>;
  cacheReporter?: (isMiss: boolean) => void;
};
 
/**
 * Be default for building cacheKey JSON.stringify function is used, thus order of keys in object matters
 */
export function memoize<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  F extends (...args: any[]) => Promise<unknown>,
  R = ReturnType<F>,
>({
  functionToMemoize,
  ttl,
  cacheKeyBuilder = (...args: unknown[]) => JSON.stringify(args),
  cacheReporter = () => {},
  cleanEveryNIteration = CLEAN_EVERY_N_ITERATION_DEFAULT,
}: MemoizeArgs<F>): F {
  const cache: Partial<Record<string, MemoizeCache<R>>> = {};
  let iterationCounter = 0;
 
  return (async (...args: Parameters<F>) => {
    const cacheKey = await cacheKeyBuilder(...args);
 
    assertWithLog(
      cacheKey.length < EXPECTED_MAX_CACHE_KEY_LENGTH_PER_FN,
      `Assumed cache key will not be longer than ${EXPECTED_MAX_CACHE_KEY_LENGTH_PER_FN}. Suspicious key ${cacheKey}`
    );
 
    // to avoid caching results forever
    iterationCounter = (iterationCounter + 1) % cleanEveryNIteration;
    if (iterationCounter == 0) {
      cleanStaleCacheEntries(cache, ttl);
    }
 
    // we don't check ttl because it is cleared here: cleanStaleCacheEntries
    const isMiss =
      !cache[cacheKey] || Date.now() - cache[cacheKey]!.lastSet > ttl;
 
    if (isMiss) {
      cache[cacheKey] = {
        lastSet: Date.now(),
        promise: functionToMemoize(...args).catch((err: unknown) => {
          // don't propagate cache when promise resolves to error
          delete cache[cacheKey];
          throw err;
        }) as Promise<R>,
      };
    }
 
    cacheReporter(isMiss);
 
    return await cache[cacheKey]!.promise;
  }) as F;
}
 
const cleanStaleCacheEntries = <T,>(
  cache: Partial<Record<string, MemoizeCache<T>>>,
  ttl: number
) => {
  const now = Date.now();
  const cacheKeys = Object.keys(cache);
 
  // we want to avoid slowing down
  assertWithLog(
    cacheKeys.length < EXPECTED_MAX_CACHE_ENTRIES_PER_FN,
    `Assumed cache key space will not grow over ${EXPECTED_MAX_CACHE_ENTRIES_PER_FN} but is ${cacheKeys.length}`
  );
 
  for (const key of cacheKeys) {
    if (now - cache[key]!.lastSet > ttl) {
      delete cache[key];
    }
  }
};