From 286a16cc524e88f2c6442c98edb220ed2a1c9518 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 29 Dec 2024 18:34:35 +0900 Subject: [PATCH] feat!: move persistent cache to new decorator (#121) --- src/index.ts | 1 + src/memoize.ts | 128 +--------- src/memoizeOne.ts | 24 +- src/memoizeWithPersistentCache.ts | 214 ++++++++++++++++ tests/unit/memoize.test.ts | 147 +---------- tests/unit/memoizeOne.test.ts | 241 +++++++++++++----- tests/unit/memoizeWithPersistentCache.test.ts | 140 ++++++++++ 7 files changed, 551 insertions(+), 344 deletions(-) create mode 100644 src/memoizeWithPersistentCache.ts create mode 100644 tests/unit/memoizeWithPersistentCache.test.ts diff --git a/src/index.ts b/src/index.ts index fdb2016..cdb816f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { memoize, memoizeFactory } from './memoize.js'; export { memoizeOne, memoizeOneFactory } from './memoizeOne.js'; +export { sha3_512 } from './hash.js'; diff --git a/src/memoize.ts b/src/memoize.ts index 5a53079..6228e40 100644 --- a/src/memoize.ts +++ b/src/memoize.ts @@ -1,7 +1,5 @@ import { sha3_512 } from './hash.js'; -let globalCounter = 0; - /** * A memoization decorator/function that caches the results of method/getter/function calls to improve performance. * This decorator/function can be applied to methods and getters in a class as a decorator, and functions without context as a function. @@ -29,28 +27,19 @@ export const memoize = memoizeFactory(); * @param {number} [options.maxCachedArgsSize=100] - The maximum number of distinct values that can be cached. * @param {number} [options.cacheDuration=Number.POSITIVE_INFINITY] - The maximum number of milliseconds that a cached value is valid. * @param {Function} [options.calcHash] - A function to calculate the hash for a given context and arguments. Defaults to hashing the stringified context and arguments. - * @param {Map[]} [options.caches] - An array of maps to store cached values. - * @param {Function} [options.persistCache] - A function to store cached values persistently. - * @param {Function} [options.tryReadingCache] - A function to try reading cached values from persistent storage. - * @param {Function} [options.removeCache] - A function to remove cached values. + * @param {Map[]} [options.caches] - An array of maps to store cached values. * @returns {Function} A new memoize function with the specified cache sizes. */ export function memoizeFactory({ cacheDuration = Number.POSITIVE_INFINITY, caches, - calcHash = (thisArg: unknown, counter: number, args: unknown) => sha3_512(JSON.stringify([thisArg, counter, args])), + calcHash = (self, args) => sha3_512(JSON.stringify([self, args])), maxCachedArgsSize = 100, - persistCache, - removeCache, - tryReadingCache, }: { maxCachedArgsSize?: number; cacheDuration?: number; - calcHash?: (thisArg: unknown, counter: number, args: unknown) => string; - caches?: Map[]; - persistCache?: (hash: string, currentTime: number, value: unknown) => void; - tryReadingCache?: (hash: string) => [number, unknown] | undefined; - removeCache?: (hash: string) => void; + calcHash?: (self: unknown, args: unknown) => string; + caches?: Map[]; } = {}) { return function memoize( target: ((this: This, ...args: Args) => Return) | ((...args: Args) => Return) | keyof This, @@ -58,83 +47,33 @@ export function memoizeFactory({ | ClassMethodDecoratorContext Return> | ClassGetterDecoratorContext ): (this: This, ...args: Args) => Return { - const counter = globalCounter++; if (context?.kind === 'getter') { const cache = new Map(); caches?.push(cache); return function (this: This): Return { console.log(`Entering getter ${String(context.name)}.`); - const hash = calcHash(this, counter, []); + const hash = calcHash(this, []); const now = Date.now(); - // Check in-memory cache first if (cache.has(hash)) { const [cachedValue, cachedAt] = cache.get(hash) as [Return, number]; if (now - cachedAt <= cacheDuration) { console.log(`Exiting getter ${String(context.name)}.`); return cachedValue; } - cache.delete(hash); - try { - const promise = removeCache?.(hash) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } - } - - // Try reading from persistent cache - try { - const persistentCache = tryReadingCache?.(hash); - if (persistentCache) { - const [cachedAt, cachedValue] = persistentCache; - if (now - cachedAt <= cacheDuration) { - cache.set(hash, [cachedValue as Return, cachedAt]); - console.log(`Exiting getter ${String(context.name)}.`); - return cachedValue as Return; - } - - const promise = removeCache?.(hash) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } - } catch { - // do nothing. } const result = (target as (this: This) => Return).call(this); if (cache.size >= maxCachedArgsSize) { const oldestKey = cache.keys().next().value as string; cache.delete(oldestKey); - try { - const promise = removeCache?.(oldestKey) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } } cache.set(hash, [result, now]); - if (result instanceof Promise) { - void (async () => { - try { - const promise = persistCache?.(hash, now, result) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } - }); - } else { - try { - const promise = persistCache?.(hash, now, result) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } - } console.log(`Exiting getter ${String(context.name)}.`); - return result as Return; + return result; }; } else { const cache = new Map(); @@ -142,45 +81,19 @@ export function memoizeFactory({ return function (this: This, ...args: Args): Return { console.log( - `Entering ${context ? `method ${String(context.name)}` : 'function'}(${calcHash(this, counter, args)}).` + `Entering ${context ? `method ${String(context.name)}` : 'function'}(${calcHash(this, args)}).` ); - const hash = calcHash(this, counter, args); + const hash = calcHash(this, args); const now = Date.now(); - // Check in-memory cache first if (cache.has(hash)) { const [cachedValue, cachedAt] = cache.get(hash) as [Return, number]; if (now - cachedAt <= cacheDuration) { console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`); return cachedValue; } - cache.delete(hash); - try { - const promise = removeCache?.(hash) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } - } - - // Try reading from persistent cache - try { - const persistentCache = tryReadingCache?.(hash); - if (persistentCache) { - const [cachedAt, cachedValue] = persistentCache; - if (now - cachedAt <= cacheDuration) { - cache.set(hash, [cachedValue as Return, cachedAt]); - console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`); - return cachedValue as Return; - } - - const promise = removeCache?.(hash) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } - } catch { - // do nothing. } const result = context @@ -190,31 +103,8 @@ export function memoizeFactory({ if (cache.size >= maxCachedArgsSize) { const oldestKey = cache.keys().next().value as string; cache.delete(oldestKey); - try { - const promise = removeCache?.(oldestKey) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } } cache.set(hash, [result, now]); - if (result instanceof Promise) { - void (async () => { - try { - const promise = persistCache?.(hash, now, result) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } - }); - } else { - try { - const promise = persistCache?.(hash, now, result) as unknown; - if (promise instanceof Promise) promise.catch(noop); - } catch { - // do nothing. - } - } console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`); return result; @@ -222,5 +112,3 @@ export function memoizeFactory({ } }; } - -const noop = (): void => {}; diff --git a/src/memoizeOne.ts b/src/memoizeOne.ts index 6f45425..040a0cc 100644 --- a/src/memoizeOne.ts +++ b/src/memoizeOne.ts @@ -19,31 +19,35 @@ export const memoizeOne = memoizeOneFactory(); * * @param {Object} options - The options for the memoizeOne function. * @param {number} [options.cacheDuration=Number.POSITIVE_INFINITY] - The maximum number of milliseconds that a cached value is valid. + * @param {Function} [options.calcHash=() => ''] - A function to calculate the hash for a given context and arguments. Defaults to returning an empty string. * * @returns {Function} A memoizeOne function with the specified cache duration. * @template This - The type of the `this` context within the method, getter or function. * @template Args - The types of the arguments to the method, getter or function. * @template Return - The return type of the method, getter or function. */ -export function memoizeOneFactory({ cacheDuration = Number.POSITIVE_INFINITY }: { cacheDuration?: number } = {}) { +export function memoizeOneFactory({ + cacheDuration = Number.POSITIVE_INFINITY, + calcHash = () => '', +}: { cacheDuration?: number; calcHash?: (self: unknown, args: unknown) => string } = {}) { return function ( target: ((this: This, ...args: Args) => Return) | ((...args: Args) => Return) | keyof This, context?: | ClassMethodDecoratorContext Return> | ClassGetterDecoratorContext ): (this: This, ...args: Args) => Return { - let lastThis: This; let lastCache: Return; let lastCachedAt: number; + let lastHash: string; if (context?.kind === 'getter') { return function (this: This): Return { console.log(`Entering getter ${String(context.name)}.`); + const hash = calcHash(this, []); const now = Date.now(); - if (lastThis !== this || now - lastCachedAt > cacheDuration) { - // eslint-disable-next-line - lastThis = this; + if (lastHash !== hash || now - lastCachedAt > cacheDuration) { + lastHash = hash; lastCache = (target as (this: This) => Return).call(this); lastCachedAt = now; } @@ -53,17 +57,13 @@ export function memoizeOneFactory({ cacheDuration = Number.POSITIVE_INFINITY }: }; } - let lastCacheKey: string; - return function (this: This, ...args: Args): Return { console.log(`Entering ${context ? `method ${String(context.name)}` : 'function'}(${JSON.stringify(args)}).`); - const key = JSON.stringify(args); + const hash = calcHash(this, args); const now = Date.now(); - if (lastThis !== this || lastCacheKey !== key || now - lastCachedAt > cacheDuration) { - // eslint-disable-next-line - lastThis = this; - lastCacheKey = key; + if (lastHash !== hash || now - lastCachedAt > cacheDuration) { + lastHash = hash; lastCache = context ? (target as (this: This, ...args: Args) => Return).call(this, ...args) : (target as (...args: Args) => Return)(...args); diff --git a/src/memoizeWithPersistentCache.ts b/src/memoizeWithPersistentCache.ts new file mode 100644 index 0000000..8bb7b60 --- /dev/null +++ b/src/memoizeWithPersistentCache.ts @@ -0,0 +1,214 @@ +import { sha3_512 } from './hash.js'; + +/** + * Factory function to create a memoize function with custom cache sizes. + * + * @template This - The type of the `this` context within the method, getter or function. + * @template Args - The types of the arguments to the method, getter or function. + * @template Return - The return type of the method, getter or function. + * @param {Object} options - The options for the memoize function. + * @param {number} [options.maxCachedArgsSize=100] - The maximum number of distinct values that can be cached. + * @param {number} [options.cacheDuration=Number.POSITIVE_INFINITY] - The maximum number of milliseconds that a cached value is valid. + * @param {Function} [options.calcHash] - A function to calculate the hash for a given context and arguments. Defaults to hashing the stringified context and arguments. + * @param {Map[]} [options.caches] - An array of maps to store cached values. + * @param {Function} options.persistCache - A function to store cached values persistently. + * @param {Function} options.tryReadingCache - A function to try reading cached values from persistent storage. + * @param {Function} options.removeCache - A function to remove cached values. + * @returns {Function} A new memoize function with the specified cache sizes. + */ +export function memoizeWithPersistentCacheFactory({ + cacheDuration = Number.POSITIVE_INFINITY, + caches, + calcHash = (self, args) => sha3_512(JSON.stringify([self, args])), + maxCachedArgsSize = 100, + persistCache, + removeCache, + tryReadingCache, +}: { + maxCachedArgsSize?: number; + cacheDuration?: number; + calcHash?: (self: unknown, args: unknown) => string; + caches?: Map[]; + persistCache: (persistentKey: string, hash: string, currentTime: number, value: unknown) => void; + tryReadingCache: (persistentKey: string, hash: string) => [number, unknown] | undefined; + removeCache: (persistentKey: string, hash: string) => void; +}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function ( + persistentKey: string + ): ( + target: ((this: This, ...args: Args) => Return) | ((...args: Args) => Return) | keyof This, + context?: + | ClassMethodDecoratorContext Return> + | ClassGetterDecoratorContext + ) => (this: This, ...args: Args) => Return { + return function memoize( + target: ((this: This, ...args: Args) => Return) | ((...args: Args) => Return) | keyof This, + context?: + | ClassMethodDecoratorContext Return> + | ClassGetterDecoratorContext + ): (this: This, ...args: Args) => Return { + if (context?.kind === 'getter') { + const cache = new Map(); + caches?.push(cache); + return function (this: This): Return { + console.log(`Entering getter ${String(context.name)}.`); + + const hash = calcHash(this, []); + const now = Date.now(); + + // Check in-memory cache first + if (cache.has(hash)) { + const [cachedValue, cachedAt] = cache.get(hash) as [Return, number]; + if (now - cachedAt <= cacheDuration) { + console.log(`Exiting getter ${String(context.name)}.`); + return cachedValue; + } + + cache.delete(hash); + try { + const promise = removeCache(persistentKey, hash) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + } + + // Try reading from persistent cache + try { + const persistentCache = tryReadingCache(persistentKey, hash); + if (persistentCache) { + const [cachedAt, cachedValue] = persistentCache; + if (now - cachedAt <= cacheDuration) { + cache.set(hash, [cachedValue as Return, cachedAt]); + console.log(`Exiting getter ${String(context.name)}.`); + return cachedValue as Return; + } + + const promise = removeCache(persistentKey, hash) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } + } catch { + // do nothing. + } + + const result = (target as (this: This) => Return).call(this); + if (cache.size >= maxCachedArgsSize) { + const oldestKey = cache.keys().next().value as string; + cache.delete(oldestKey); + try { + const promise = removeCache(persistentKey, oldestKey) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + } + cache.set(hash, [result, now]); + if (result instanceof Promise) { + void (async () => { + try { + const promise = persistCache(persistentKey, hash, now, result) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + }); + } else { + try { + const promise = persistCache(persistentKey, hash, now, result) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + } + + console.log(`Exiting getter ${String(context.name)}.`); + return result as Return; + }; + } else { + const cache = new Map(); + caches?.push(cache); + + return function (this: This, ...args: { [K in keyof Args]: Args[K] }): Return { + console.log(`Entering ${context ? `method ${String(context.name)}` : 'function'}(${calcHash(this, args)}).`); + + const hash = calcHash(this, args); + const now = Date.now(); + + // Check in-memory cache first + if (cache.has(hash)) { + const [cachedValue, cachedAt] = cache.get(hash) as [Return, number]; + if (now - cachedAt <= cacheDuration) { + console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`); + return cachedValue; + } + + cache.delete(hash); + try { + const promise = removeCache(persistentKey, hash) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + } + + // Try reading from persistent cache + try { + const persistentCache = tryReadingCache(persistentKey, hash); + if (persistentCache) { + const [cachedAt, cachedValue] = persistentCache; + if (now - cachedAt <= cacheDuration) { + cache.set(hash, [cachedValue as Return, cachedAt]); + console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`); + return cachedValue as Return; + } + + const promise = removeCache(persistentKey, hash) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } + } catch { + // do nothing. + } + + const result = context + ? (target as (this: This, ...args: Args) => Return).call(this, ...args) + : (target as (...args: Args) => Return)(...args); + + if (cache.size >= maxCachedArgsSize) { + const oldestKey = cache.keys().next().value as string; + cache.delete(oldestKey); + try { + const promise = removeCache(persistentKey, oldestKey) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + } + cache.set(hash, [result, now]); + if (result instanceof Promise) { + void (async () => { + try { + const promise = persistCache(persistentKey, hash, now, result) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + }); + } else { + try { + const promise = persistCache(persistentKey, hash, now, result) as unknown; + if (promise instanceof Promise) promise.catch(noop); + } catch { + // do nothing. + } + } + + console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`); + return result; + }; + } + }; + }; +} + +const noop = (): void => {}; diff --git a/tests/unit/memoize.test.ts b/tests/unit/memoize.test.ts index 2adff48..854c10b 100644 --- a/tests/unit/memoize.test.ts +++ b/tests/unit/memoize.test.ts @@ -15,12 +15,12 @@ describe('memory cache', () => { @memoize nextInteger(base = 0): number { - return base + getNextInteger(); + return this._count * base + getNextInteger(); } @memoize nextString(base = 0): string { - return String(base + getNextInteger()); + return String(this._count * base + getNextInteger()); } abstract get count(): number; @@ -86,13 +86,11 @@ describe('memory cache', () => { }); test('memoize getter per instance', () => { - expect(random1.count).toBe(1); - expect(random1.count).toBe(1); - expect(random2.count).toBe(10); - expect(random2.count).toBe(10); + expect(random1.count).toBe(random1.count); + expect(random2.count).toBe(random2.count); }); - test('memoizeFactory with 0 cacheDuration', () => { + test('memoizeFactory with -1 cacheDuration', () => { expect(nextInteger2()).not.toBe(nextInteger2()); expect(nextInteger2(100)).not.toBe(nextInteger2(100)); }); @@ -123,138 +121,3 @@ describe('memory cache', () => { expect(k.obj).toEqual({ a: 'b' }); }); }); - -describe('persistent cache', () => { - const persistentStore = new Map(); - - function persistCache(hash: string, currentTime: number, value: unknown): void { - persistentStore.set(hash, [currentTime, value]); - } - - function tryReadingCache(hash: string): [number, unknown] | undefined { - return persistentStore.get(hash); - } - - function removeCache(hash: string): void { - persistentStore.delete(hash); - } - - const caches: Map[] = []; - const memoize = memoizeFactory({ - caches, - persistCache, - tryReadingCache, - removeCache, - cacheDuration: 200, - }); - const nextIntegerWithPersistence = memoize((base: number = 0): number => base + getNextInteger()); - const nextIntegerWithPersistence2 = memoize((base: number = 0): number => base + getNextInteger()); - - function clearCache(): void { - for (const cache of caches) { - cache.clear(); - } - } - - beforeEach(() => { - persistentStore.clear(); - clearCache(); - }); - - test('persist cache per method', () => { - const initial = nextIntegerWithPersistence(100); - clearCache(); - - expect(nextIntegerWithPersistence(100)).toBe(initial); - expect(persistentStore.size).toBe(1); - - expect(nextIntegerWithPersistence2(100)).not.toBe(initial); - expect(persistentStore.size).toBe(2); - }); - - test('remove expired cache', async () => { - const initial = nextIntegerWithPersistence(100); - clearCache(); - - expect(persistentStore.size).toBe(1); - await setTimeout(400); - const second = nextIntegerWithPersistence(100); - expect(second).not.toBe(initial); - expect(persistentStore.size).toBe(1); - }); - - test('handle multiple cache entries', () => { - const value1 = nextIntegerWithPersistence(100); - const value2 = nextIntegerWithPersistence(200); - clearCache(); - - expect(persistentStore.size).toBe(2); - expect(nextIntegerWithPersistence(100)).toBe(value1); - expect(nextIntegerWithPersistence(200)).toBe(value2); - }); - - test('remove oldest cache entry when maxCachedArgsSize is reached', () => { - const withSizeLimit = memoizeFactory({ - persistCache, - tryReadingCache, - removeCache, - maxCachedArgsSize: 2, - })((base: number = 0): number => base + getNextInteger()); - - const value1 = withSizeLimit(100); - const value2 = withSizeLimit(200); - const value3 = withSizeLimit(300); - clearCache(); - - expect(persistentStore.size).toBe(2); - expect(withSizeLimit(300)).toBe(value3); - expect(withSizeLimit(200)).toBe(value2); - expect(withSizeLimit(100)).not.toBe(value1); - }); -}); - -function errorThrowingPersistCache(): never { - throw new Error('Persist error'); -} - -function errorThrowingTryReadingCache(): never { - throw new Error('Read error'); -} - -function errorThrowingRemoveCache(): never { - throw new Error('Remove error'); -} - -describe('error handling in cache operations', () => { - const nextIntegerWithErrorHandling = memoizeFactory({ - persistCache: errorThrowingPersistCache, - tryReadingCache: errorThrowingTryReadingCache, - removeCache: errorThrowingRemoveCache, - cacheDuration: 200, - })((base: number = 0): number => base + getNextInteger()); - - test('ignore errors in persistCache, tryReadingCache and removeCache', () => { - expect(() => nextIntegerWithErrorHandling(100)).not.toThrow(); - }); -}); - -async function asyncErrorThrowingPersistCache(): Promise { - throw new Error('Persist error'); -} - -async function asyncErrorThrowingRemoveCache(): Promise { - throw new Error('Remove error'); -} - -describe('async error handling in cache operations', () => { - const nextIntegerWithAsyncErrorHandling = memoizeFactory({ - persistCache: asyncErrorThrowingPersistCache, - tryReadingCache: errorThrowingTryReadingCache, - removeCache: asyncErrorThrowingRemoveCache, - cacheDuration: 200, - })((base: number = 0): number => base + getNextInteger()); - - test('ignore errors in async persistCache, non-async tryReadingCache and async removeCache', async () => { - expect(() => nextIntegerWithAsyncErrorHandling(100)).not.toThrow(); - }); -}); diff --git a/tests/unit/memoizeOne.test.ts b/tests/unit/memoizeOne.test.ts index c48dba9..e334b16 100644 --- a/tests/unit/memoizeOne.test.ts +++ b/tests/unit/memoizeOne.test.ts @@ -1,96 +1,197 @@ import { setTimeout } from 'node:timers/promises'; +import { sha3_512 } from '../../src/hash.js'; import { memoizeOne, memoizeOneFactory } from '../../src/memoizeOne.js'; import { getNextInteger } from './shared.js'; -abstract class Random { - _count: number; +describe('memoizeOne with default calcHash which returns empty string', () => { + abstract class Random { + _count: number; - constructor(initialCount = 1) { - this._count = initialCount; + constructor(initialCount = 1) { + this._count = initialCount; + } + + @memoizeOne + nextInteger(base = 0): number { + return this._count * base + getNextInteger(); + } + + abstract get count(): number; } - @memoizeOne - nextInteger(base = 0): number { - return base + getNextInteger(); + class RandomChild extends Random { + @memoizeOne + get count(): number { + return this._count++; + } } - abstract get count(): number; -} + const random1 = new RandomChild(); + const random2 = new RandomChild(10); -class RandomChild extends Random { - @memoizeOne - get count(): number { - return this._count++; - } -} + const nextInteger1 = memoizeOne((base: number = 0): number => base + getNextInteger()); + const nextInteger2 = memoizeOneFactory({ cacheDuration: -1 })((base: number = 0): number => base + getNextInteger()); + const nextInteger3 = memoizeOneFactory({ cacheDuration: 200 })((base: number = 0): number => base + getNextInteger()); + const asyncNextInteger1 = memoizeOne(async (base: number = 0): Promise => { + await setTimeout(0); + return base + getNextInteger(); + }); -const random1 = new RandomChild(); -const random2 = new RandomChild(10); + test.each([ + ['with', (...args: number[]) => random1.nextInteger(...args)], + ['without', (...args: number[]) => nextInteger1(...args)], + ])('memoize function %s decorator', (_, func) => { + expect(func()).toBe(func()); + expect(func(100)).toBe(func(100)); + expect(func(0)).toBe(func(100)); + + const cache1 = func(); + const cache2 = func(100); + expect(cache1).toBe(func()); + expect(cache2).toBe(func(100)); + }); -const nextInteger1 = memoizeOne((base: number = 0): number => base + getNextInteger()); -const nextInteger2 = memoizeOneFactory({ cacheDuration: -1 })((base: number = 0): number => base + getNextInteger()); -const nextInteger3 = memoizeOneFactory({ cacheDuration: 200 })((base: number = 0): number => base + getNextInteger()); -const asyncNextInteger = memoizeOne(async (base: number = 0): Promise => { - await setTimeout(0); - return base + getNextInteger(); -}); + test('memoize async function', async () => { + expect(typeof (await asyncNextInteger1())).toBe('number'); + expect(await asyncNextInteger1()).toBe(await asyncNextInteger1()); + expect(await asyncNextInteger1(100)).toBe(await asyncNextInteger1(100)); + expect(await asyncNextInteger1(0)).toBe(await asyncNextInteger1(100)); + }); -test.each([ - ['with', (...args: number[]) => random1.nextInteger(...args)], - ['without', (...args: number[]) => nextInteger1(...args)], -])('memoizeOne function %s decorator', (_, func) => { - expect(func()).toBe(func()); - expect(func(100)).toBe(func(100)); - expect(func(0)).not.toBe(func(100)); - - const cache1 = func(); - const cache2 = func(100); - expect(cache1).not.toBe(func()); - expect(cache2).not.toBe(func(100)); -}); + test('memoize method ignoring instance difference', () => { + expect(random1.nextInteger()).toBe(random2.nextInteger()); + expect(random1.nextInteger(100)).toBe(random2.nextInteger(100)); + }); -test('memoize async function', async () => { - expect(typeof (await asyncNextInteger())).toBe('number'); - expect(await asyncNextInteger()).toBe(await asyncNextInteger()); - expect(await asyncNextInteger(100)).toBe(await asyncNextInteger(100)); - expect(await asyncNextInteger(0)).not.toBe(await asyncNextInteger(100)); -}); + test('memoize getter ignoring instance difference', () => { + expect(random1.count).toBe(random1.count); + expect(random2.count).toBe(random2.count); + expect(random1.count).toBe(random2.count); + }); -test('memoize method per instance', () => { - expect(random1.nextInteger()).not.toBe(random2.nextInteger()); - expect(random1.nextInteger(100)).not.toBe(random2.nextInteger(100)); -}); + test('memoizeFactory with -1 cacheDuration', () => { + expect(nextInteger2()).not.toBe(nextInteger2()); + expect(nextInteger2(100)).not.toBe(nextInteger2(100)); + }); -test('memoizeOne getter per instance', () => { - expect(random1.count).toBe(1); - expect(random1.count).toBe(1); - expect(random2.count).toBe(10); - expect(random2.count).toBe(10); -}); + test('memoizeFactory with 200 cacheDuration', async () => { + const initial = nextInteger3(); + expect(nextInteger3()).toBe(initial); + expect(nextInteger3()).toBe(initial); + await setTimeout(400); + const second = nextInteger3(); + expect(second).not.toBe(initial); + expect(nextInteger3()).toBe(second); + expect(nextInteger3()).toBe(second); + }); -test('memoizeFactory with 0 cacheDuration', () => { - expect(nextInteger2()).not.toBe(nextInteger2()); - expect(nextInteger2(100)).not.toBe(nextInteger2(100)); -}); + test('memoize async function with exception', async () => { + const asyncErrorFunction = memoizeOne(async () => { + await setTimeout(0); + throw new Error('Test error'); + }); -test('memoizeFactory with 200 cacheDuration', async () => { - const initial = nextInteger3(); - expect(nextInteger3()).toBe(initial); - expect(nextInteger3()).toBe(initial); - await setTimeout(400); - const second = nextInteger3(); - expect(second).not.toBe(initial); - expect(nextInteger3()).toBe(second); - expect(nextInteger3()).toBe(second); + await expect(asyncErrorFunction()).rejects.toThrow('Test error'); + }); }); -test('memoizeOne async function with exception', async () => { - const asyncErrorFunction = memoizeOne(async () => { +describe('with specified calcHash', () => { + const memoizeOneWithHash = memoizeOneFactory({ calcHash: (self, args) => sha3_512(JSON.stringify([self, args])) }); + + abstract class Random { + _count: number; + + constructor(initialCount = 1) { + this._count = initialCount; + } + + @memoizeOneWithHash + nextInteger(base = 0): number { + return this._count * base + getNextInteger(); + } + + abstract get count(): number; + } + + class RandomChild extends Random { + @memoizeOneWithHash + get count(): number { + return this._count++; + } + } + + const random1 = new RandomChild(); + const random2 = new RandomChild(10); + + const nextInteger1 = memoizeOneWithHash((base: number = 0): number => base + getNextInteger()); + const nextInteger2 = memoizeOneFactory({ + cacheDuration: -1, + calcHash: (self, args) => sha3_512(JSON.stringify([self, args])), + })((base: number = 0): number => base + getNextInteger()); + const nextInteger3 = memoizeOneFactory({ + cacheDuration: 200, + calcHash: (self, args) => sha3_512(JSON.stringify([self, args])), + })((base: number = 0): number => base + getNextInteger()); + const asyncNextInteger1 = memoizeOneWithHash(async (base: number = 0): Promise => { await setTimeout(0); - throw new Error('Test error'); + return base + getNextInteger(); + }); + + test.each([ + ['with', (...args: number[]) => random1.nextInteger(...args)], + ['without', (...args: number[]) => nextInteger1(...args)], + ])('memoize function %s decorator', (_, func) => { + expect(func()).toBe(func()); + expect(func(100)).toBe(func(100)); + expect(func(0)).not.toBe(func(100)); + + const cache1 = func(); + const cache2 = func(100); + expect(cache1).not.toBe(func()); + expect(cache2).not.toBe(func(100)); + }); + + test('memoize async function', async () => { + expect(typeof (await asyncNextInteger1())).toBe('number'); + expect(await asyncNextInteger1()).toBe(await asyncNextInteger1()); + expect(await asyncNextInteger1(100)).toBe(await asyncNextInteger1(100)); + expect(await asyncNextInteger1(0)).not.toBe(await asyncNextInteger1(100)); }); - await expect(asyncErrorFunction()).rejects.toThrow('Test error'); + test('memoize method per instance', () => { + expect(random1.nextInteger()).not.toBe(random2.nextInteger()); + expect(random1.nextInteger(100)).not.toBe(random2.nextInteger(100)); + }); + + test('memoize getter per instance', () => { + expect(random1.count).not.toBe(random1.count); + expect(random2.count).not.toBe(random2.count); + }); + + test('memoizeFactory with -1 cacheDuration', () => { + expect(nextInteger2()).not.toBe(nextInteger2()); + expect(nextInteger2(100)).not.toBe(nextInteger2(100)); + }); + + test('memoizeFactory with 200 cacheDuration', async () => { + const initial = nextInteger3(); + expect(nextInteger3()).toBe(initial); + expect(nextInteger3()).toBe(initial); + await setTimeout(400); + const second = nextInteger3(); + expect(second).not.toBe(initial); + expect(nextInteger3()).toBe(second); + expect(nextInteger3()).toBe(second); + }); + + test('memoize async function with exception', async () => { + const asyncErrorFunction = memoizeOneWithHash(async () => { + await setTimeout(0); + throw new Error('Test error'); + }); + + await expect(asyncErrorFunction()).rejects.toThrow('Test error'); + }); }); diff --git a/tests/unit/memoizeWithPersistentCache.test.ts b/tests/unit/memoizeWithPersistentCache.test.ts new file mode 100644 index 0000000..a77345c --- /dev/null +++ b/tests/unit/memoizeWithPersistentCache.test.ts @@ -0,0 +1,140 @@ +import { setTimeout } from 'node:timers/promises'; + +import { memoizeWithPersistentCacheFactory } from '../../src/memoizeWithPersistentCache.js'; + +import { getNextInteger } from './shared.js'; + +describe('persistent cache', () => { + const persistentStore = new Map(); + + function persistCache(persistentKey: string, hash: string, currentTime: number, value: unknown): void { + persistentStore.set(`${persistentKey}_${hash}`, [currentTime, value]); + } + + function tryReadingCache(persistentKey: string, hash: string): [number, unknown] | undefined { + return persistentStore.get(`${persistentKey}_${hash}`); + } + + function removeCache(persistentKey: string, hash: string): void { + persistentStore.delete(`${persistentKey}_${hash}`); + } + + const caches: Map[] = []; + const memoize = memoizeWithPersistentCacheFactory({ + caches, + persistCache, + tryReadingCache, + removeCache, + cacheDuration: 200, + }); + const nextIntegerWithPersistence = memoize('nextInteger')((base: number = 0): number => base + getNextInteger()); + const nextIntegerWithPersistence2 = memoize('nextInteger2')((base: number = 0): number => base + getNextInteger()); + + function clearCache(): void { + for (const cache of caches) { + cache.clear(); + } + } + + beforeEach(() => { + persistentStore.clear(); + clearCache(); + }); + + test('persist cache per method', () => { + const initial = nextIntegerWithPersistence(100); + clearCache(); + + expect(nextIntegerWithPersistence(100)).toBe(initial); + expect(persistentStore.size).toBe(1); + + expect(nextIntegerWithPersistence2(100)).not.toBe(initial); + expect(persistentStore.size).toBe(2); + }); + + test('remove expired cache', async () => { + const initial = nextIntegerWithPersistence(100); + clearCache(); + + expect(persistentStore.size).toBe(1); + await setTimeout(400); + const second = nextIntegerWithPersistence(100); + expect(second).not.toBe(initial); + expect(persistentStore.size).toBe(1); + }); + + test('handle multiple cache entries', () => { + const value1 = nextIntegerWithPersistence(100); + const value2 = nextIntegerWithPersistence(200); + clearCache(); + + expect(persistentStore.size).toBe(2); + expect(nextIntegerWithPersistence(100)).toBe(value1); + expect(nextIntegerWithPersistence(200)).toBe(value2); + }); + + test('remove oldest cache entry when maxCachedArgsSize is reached', () => { + const withSizeLimit = memoizeWithPersistentCacheFactory({ + persistCache, + tryReadingCache, + removeCache, + maxCachedArgsSize: 2, + })('nextInteger')((base: number = 0): number => base + getNextInteger()); + + const value1 = withSizeLimit(100); + const value2 = withSizeLimit(200); + const value3 = withSizeLimit(300); + clearCache(); + + expect(persistentStore.size).toBe(2); + expect(withSizeLimit(300)).toBe(value3); + expect(withSizeLimit(200)).toBe(value2); + expect(withSizeLimit(100)).not.toBe(value1); + }); +}); + +function errorThrowingPersistCache(): never { + throw new Error('Persist error'); +} + +function errorThrowingTryReadingCache(): never { + throw new Error('Read error'); +} + +function errorThrowingRemoveCache(): never { + throw new Error('Remove error'); +} + +describe('error handling in cache operations', () => { + const nextIntegerWithErrorHandling = memoizeWithPersistentCacheFactory({ + persistCache: errorThrowingPersistCache, + tryReadingCache: errorThrowingTryReadingCache, + removeCache: errorThrowingRemoveCache, + cacheDuration: 200, + })('nextInteger')((base: number = 0): number => base + getNextInteger()); + + test('ignore errors in persistCache, tryReadingCache and removeCache', () => { + expect(() => nextIntegerWithErrorHandling(100)).not.toThrow(); + }); +}); + +async function asyncErrorThrowingPersistCache(): Promise { + throw new Error('Persist error'); +} + +async function asyncErrorThrowingRemoveCache(): Promise { + throw new Error('Remove error'); +} + +describe('async error handling in cache operations', () => { + const nextIntegerWithAsyncErrorHandling = memoizeWithPersistentCacheFactory({ + persistCache: asyncErrorThrowingPersistCache, + tryReadingCache: errorThrowingTryReadingCache, + removeCache: asyncErrorThrowingRemoveCache, + cacheDuration: 200, + })('nextInteger')((base: number = 0): number => base + getNextInteger()); + + test('ignore errors in async persistCache, non-async tryReadingCache and async removeCache', async () => { + expect(() => nextIntegerWithAsyncErrorHandling(100)).not.toThrow(); + }); +});