Skip to content

Commit

Permalink
feat(contentful): memoizer
Browse files Browse the repository at this point in the history
  • Loading branch information
dpinol committed Jun 14, 2021
1 parent 8fd74c9 commit 2b51f0d
Show file tree
Hide file tree
Showing 2 changed files with 278 additions and 0 deletions.
106 changes: 106 additions & 0 deletions packages/botonic-plugin-contentful/src/util/memoizer.ts
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 packages/botonic-plugin-contentful/tests/util/memoizer.test.ts
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)
}

0 comments on commit 2b51f0d

Please sign in to comment.