-
Notifications
You must be signed in to change notification settings - Fork 75
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
278 additions
and
0 deletions.
There are no files selected for viewing
106 changes: 106 additions & 0 deletions
106
packages/botonic-plugin-contentful/src/util/memoizer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
export class Cache<V> { | ||
static readonly NOT_FOUND = Symbol('NOT_FOUND') | ||
cache: Record<string, V> = {} | ||
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 extends any[], Return> = ( | ||
...args: Args | ||
) => Promise<Return> //& { cache: Cache<Return> } | ||
|
||
export type MemoizerStrategy = <Args extends any[], Return>( | ||
cache: Cache<Return>, | ||
normalizer: typeof jsonNormalizer, | ||
func: (...args: Args) => Promise<Return>, | ||
...args: Args | ||
) => Promise<Return> | ||
|
||
export class Memoizer { | ||
constructor( | ||
private readonly strategy: MemoizerStrategy, | ||
private readonly cacheFactory = () => new Cache<any>(), | ||
private readonly normalizer = jsonNormalizer | ||
) {} | ||
|
||
memoize< | ||
Args extends any[], | ||
Return, | ||
F extends (...args: Args) => Promise<Return> | ||
>(func: F): F { | ||
const cache: Cache<Return> = this.cacheFactory() | ||
const f = (...args: Args) => | ||
this.strategy<Args, Return>(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<Return>, | ||
normalizer = jsonNormalizer, | ||
func: (...args: Args) => Promise<Return>, | ||
...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<void> | ||
): MemoizerStrategy { | ||
return async <Args extends any[], Return>( | ||
cache: Cache<Return>, | ||
normalizer = jsonNormalizer, | ||
func: (...args: Args) => Promise<Return>, | ||
...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 | ||
} | ||
} | ||
} |
172 changes: 172 additions & 0 deletions
172
packages/botonic-plugin-contentful/tests/util/memoizer.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string> { | ||
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<string> { | ||
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<void> { | ||
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) | ||
} |