diff --git a/packages/botonic-plugin-contentful/src/util/backoff.ts b/packages/botonic-plugin-contentful/src/util/backoff.ts index 4a29dade3a..ed4780b264 100644 --- a/packages/botonic-plugin-contentful/src/util/backoff.ts +++ b/packages/botonic-plugin-contentful/src/util/backoff.ts @@ -1,4 +1,4 @@ -export function sleep(ms: number) { +export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } diff --git a/packages/botonic-plugin-contentful/src/util/memoizer.ts b/packages/botonic-plugin-contentful/src/util/memoizer.ts new file mode 100644 index 0000000000..8f61338813 --- /dev/null +++ b/packages/botonic-plugin-contentful/src/util/memoizer.ts @@ -0,0 +1,106 @@ +export class Cache { + static readonly NOT_FOUND = Symbol('NOT_FOUND') + cache: Record = {} + set(id: string, val: V): void { + this.cache[id] = val + } + get(id: string): V | typeof Cache.NOT_FOUND { + // Cache.has is only checked when undefined to avoid always searching twice in the object + const val = this.cache[id] + if (val === undefined && !this.has(id)) { + return Cache.NOT_FOUND + } + return val + } + has(id: string): boolean { + return id in this.cache + } +} + +export type MemoizerNormalizer = (...args: any) => string + +export const jsonNormalizer: MemoizerNormalizer = (...args: any) => { + return JSON.stringify(args) +} + +export type MemoizedFunction = ( + ...args: Args +) => Promise //& { cache: Cache } + +export type MemoizerStrategy = ( + cache: Cache, + normalizer: typeof jsonNormalizer, + func: (...args: Args) => Promise, + ...args: Args +) => Promise + +export class Memoizer { + constructor( + private readonly strategy: MemoizerStrategy, + private readonly cacheFactory = () => new Cache(), + private readonly normalizer = jsonNormalizer + ) {} + + memoize< + Args extends any[], + Return, + F extends (...args: Args) => Promise + >(func: F): F { + const cache: Cache = this.cacheFactory() + const f = (...args: Args) => + this.strategy(cache, this.normalizer, func, ...args) + // f.cache = cache + return f as F + } +} + +/*** + * Only reinvoke if not in cache + */ +export const cacheForeverStrategy: MemoizerStrategy = async < + Args extends any[], + Return +>( + cache: Cache, + normalizer = jsonNormalizer, + func: (...args: Args) => Promise, + ...args: Args +) => { + const id = normalizer(...args) + let val = cache.get(id) + if (val === Cache.NOT_FOUND) { + val = await func(...args) + cache.set(id, val) + } + return val +} + +/** + * Always invokes the function, but fallbacks to last invocation result if available + */ +export function fallbackStrategy( + usingFallback: (functName: string, args: any[], error: any) => Promise +): MemoizerStrategy { + return async ( + cache: Cache, + normalizer = jsonNormalizer, + func: (...args: Args) => Promise, + ...args: Args + ) => { + // return func(...args) + const id = normalizer(...args) + const oldVal = cache.get(id) + + try { + const newVal = await func(...args) + cache.set(id, newVal) + return newVal + } catch (e) { + if (oldVal !== Cache.NOT_FOUND) { + await usingFallback(String(func.name), args, e) + return oldVal + } + throw e + } + } +} diff --git a/packages/botonic-plugin-contentful/tests/util/memoizer.test.ts b/packages/botonic-plugin-contentful/tests/util/memoizer.test.ts new file mode 100644 index 0000000000..55334b4711 --- /dev/null +++ b/packages/botonic-plugin-contentful/tests/util/memoizer.test.ts @@ -0,0 +1,172 @@ +import { + cacheForeverStrategy, + fallbackStrategy, + Memoizer, +} from '../../src/util/memoizer' + +class MockF { + callsFA = 0 + callsFB = 0 + error: Error | undefined + salt = '' + + fA(a: number, b: string): Promise { + this.callsFA++ + if (this.error) { + return Promise.reject(this.error) + } + return Promise.resolve('A' + String(a) + b + this.salt) + } + + fB(a = 1, b = 'b'): Promise { + this.callsFB++ + if (this.error) { + return Promise.reject(this.error) + } + return Promise.resolve('B' + String(a) + b + this.salt) + } +} + +describe('Memoizer', () => { + it('TEST: cacheForeverStrategy common properties', async () => { + await assertCommonStrategyProperties( + () => new Memoizer(cacheForeverStrategy), + false + ) + }) + it('TEST: cacheForeverStrategy only fails if last call failed', async () => { + const sut = new Memoizer(cacheForeverStrategy) + const mock = new MockF() + const memoized = sut.memoize(mock.fA.bind(mock)) + + // act/assert + mock.error = new Error('forced failure') + await expect(memoized(2, 'b')).rejects.toThrowError(mock.error) + mock.error = undefined + await expect(memoized(2, 'b')).resolves.toBe('A2b') + expect(mock.callsFA).toBe(2) + }) + + function usingFallback(funcName: string, args: any[], e: any): Promise { + console.error( + `Using fallback for ${funcName}(${String(args)}) after error: ${String( + e + )}` + ) + return Promise.resolve() + } + + it('TEST: fallbackStrategy common properties', async () => { + await assertCommonStrategyProperties( + () => new Memoizer(fallbackStrategy(usingFallback)), + true + ) + }) + + it('TEST: fallbackStrategy uses last invocation result', async () => { + const sut = new Memoizer(fallbackStrategy(usingFallback)) + const mock = new MockF() + const memoized = sut.memoize(mock.fA.bind(mock)) + + // act/assert + await expect(memoized(2, 'b')).resolves.toBe('A2b') + mock.salt = 'pepper' + await expect(memoized(2, 'b')).resolves.toBe('A2b' + mock.salt) + expect(mock.callsFA).toBe(2) + }) + + test('TEST: fallbackStrategy returns latest success return if function fails', async () => { + const sut = new Memoizer(fallbackStrategy(usingFallback)) + const mock = new MockF() + const memoized = sut.memoize(mock.fA.bind(mock)) + + // arrange + await memoized(2, 'b') + mock.salt = 'pepper' + await memoized(2, 'b') + + mock.error = new Error('forced failure') + await memoized(2, 'b') + + // act + mock.error = undefined + await expect(memoized(2, 'b')).resolves.toBe('A2bpepper') + expect(mock.callsFA).toBe(4) + }) +}) + +async function assertCommonStrategyProperties( + sutFactory: () => Memoizer, + expectReinvocation: boolean +) { + await memoizerDoesNotMixUpDifferentFunctionsOrArguments( + sutFactory(), + expectReinvocation + ) + await memoizerFailsIfAllPreviousInvocationsFailed(sutFactory()) + await memoizerWorksWithDefaultValueArgs(sutFactory(), expectReinvocation) +} + +async function memoizerFailsIfAllPreviousInvocationsFailed(sut: Memoizer) { + const mock = new MockF() + const memoized = sut.memoize(mock.fA.bind(mock)) + + // act/assert + mock.error = new Error('forced failure') + await expect(memoized(2, 'b')).rejects.toThrowError(mock.error) + await expect(memoized(2, 'b')).rejects.toThrowError(mock.error) + + mock.error = undefined + await expect(memoized(2, 'b')).resolves.toBe('A2b') +} + +async function memoizerDoesNotMixUpDifferentFunctionsOrArguments( + sut: Memoizer, + expectReinvocation: boolean +) { + const mock = new MockF() + const memoizedA = sut.memoize(mock.fA.bind(mock)) + const memoizedB = sut.memoize(mock.fB.bind(mock)) + + // act/assert + await expect(memoizedA(2, 'b')).resolves.toBe('A2b') + //same function, arguments + await expect(memoizedA(2, 'b')).resolves.toBe('A2b') + expect(mock.callsFA).toBe(expectReinvocation ? 2 : 1) + + //same function, different arguments + await expect(memoizedA(2, 'c')).resolves.toBe('A2c') + expect(mock.callsFA).toBe(expectReinvocation ? 3 : 2) + + //different function, same arguments + await expect(memoizedB(2, 'b')).resolves.toBe('B2b') + expect(mock.callsFB).toBe(expectReinvocation ? 1 : 1) +} + +async function memoizerWorksWithDefaultValueArgs( + sut: Memoizer, + expectReinvocation: boolean +) { + const mock = new MockF() + const memoized = sut.memoize(mock.fB.bind(mock)) + + // act/assert + // All default arguments + await expect(memoized()).resolves.toBe('B1b') + await expect(memoized()).resolves.toBe('B1b') + expect(mock.callsFB).toBe(expectReinvocation ? 2 : 1) + + // explicitly passing the default value + await expect(memoized(1, 'b')).resolves.toBe('B1b') + await expect(memoized(1, 'b')).resolves.toBe('B1b') + expect(mock.callsFB).toBe(expectReinvocation ? 4 : 2) + + // 1 argument by default, 1 explicit + await expect(memoized(4)).resolves.toBe('B4b') + await expect(memoized(4)).resolves.toBe('B4b') + expect(mock.callsFB).toBe(expectReinvocation ? 6 : 3) + + await expect(memoized(4, 'c')).resolves.toBe('B4c') + await expect(memoized(4, 'c')).resolves.toBe('B4c') + expect(mock.callsFB).toBe(expectReinvocation ? 8 : 4) +}