parent
e38fb2408a
commit
adbb5edb4b
@ -0,0 +1,144 @@ |
||||
import { sleep } from '../utils/eventLoop'; |
||||
import { StringifiableValue } from '../utils/serializer'; |
||||
import { CacheEntry, createCachedDataProvider } from './cache'; |
||||
import { createKeyValueStorage } from './inMemoryDB'; |
||||
|
||||
type ValueType = Record<string, StringifiableValue>; |
||||
|
||||
describe('createCachedDataProvider', () => { |
||||
it('always fetches remote data when ttl is zero, for sequential calls', async () => { |
||||
const calledWith: unknown[] = []; |
||||
const getValue = createCachedDataProvider({ |
||||
cacheStorage: createKeyValueStorage<CacheEntry<ValueType>>(0), |
||||
getNewValue: (key) => { |
||||
calledWith.push(key); |
||||
return Promise.resolve(key); |
||||
}, |
||||
ttlMs: 0, |
||||
}); |
||||
|
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await sleep(10); // to make sure that Date.now() does not always return the same value
|
||||
expect(await getValue({ key: 2 })).toEqual({ key: 2 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 3 })).toEqual({ key: 3 }); |
||||
|
||||
expect(calledWith).toEqual([ |
||||
{ key: 1 }, |
||||
{ key: 2 }, |
||||
{ key: 1 }, |
||||
{ key: 1 }, |
||||
{ key: 3 }, |
||||
]); |
||||
}); |
||||
|
||||
it('never refetches remote data when ttl is 1 hour, for sequential calls', async () => { |
||||
const calledWith: unknown[] = []; |
||||
const getValue = createCachedDataProvider({ |
||||
cacheStorage: createKeyValueStorage<CacheEntry<ValueType>>(0), |
||||
getNewValue: (key) => { |
||||
calledWith.push(key); |
||||
return Promise.resolve(key); |
||||
}, |
||||
ttlMs: 3_600_000, |
||||
}); |
||||
|
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 2 })).toEqual({ key: 2 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await sleep(10); |
||||
expect(await getValue({ key: 3 })).toEqual({ key: 3 }); |
||||
|
||||
expect(calledWith).toEqual([{ key: 1 }, { key: 2 }, { key: 3 }]); |
||||
}); |
||||
|
||||
it('refetches remote data when ttl is 1 hour but cache is reset', async () => { |
||||
const cacheStorage = createKeyValueStorage<CacheEntry<ValueType>>(0); |
||||
const calledWith: unknown[] = []; |
||||
const getValue = createCachedDataProvider({ |
||||
cacheStorage, |
||||
getNewValue: (key) => { |
||||
calledWith.push(key); |
||||
return Promise.resolve(key); |
||||
}, |
||||
ttlMs: 3_600_000, |
||||
}); |
||||
|
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
await cacheStorage.clear(); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1 }); |
||||
|
||||
expect(calledWith).toEqual([{ key: 1 }, { key: 1 }]); |
||||
}); |
||||
|
||||
it('refetches remote data after 200ms when ttl is 100ms', async () => { |
||||
const calledWith: unknown[] = []; |
||||
const getValue = createCachedDataProvider({ |
||||
cacheStorage: createKeyValueStorage<CacheEntry<ValueType>>(0), |
||||
getNewValue: (key: ValueType) => { |
||||
const callNumber = calledWith.length; |
||||
calledWith.push(key); |
||||
return Promise.resolve({ ...key, callNumber }); |
||||
}, |
||||
ttlMs: 100, |
||||
}); |
||||
|
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1, callNumber: 0 }); |
||||
expect(await getValue({ key: 2 })).toEqual({ key: 2, callNumber: 1 }); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1, callNumber: 0 }); |
||||
expect(await getValue({ key: 2 })).toEqual({ key: 2, callNumber: 1 }); |
||||
await sleep(200); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1, callNumber: 2 }); |
||||
expect(await getValue({ key: 2 })).toEqual({ key: 2, callNumber: 3 }); |
||||
expect(await getValue({ key: 1 })).toEqual({ key: 1, callNumber: 2 }); |
||||
expect(await getValue({ key: 2 })).toEqual({ key: 2, callNumber: 3 }); |
||||
|
||||
expect(calledWith).toEqual([ |
||||
{ key: 1 }, |
||||
{ key: 2 }, |
||||
{ key: 1 }, |
||||
{ key: 2 }, |
||||
]); |
||||
}); |
||||
|
||||
it('never refetches remote data even when ttl is zero, for concurrent calls', async () => { |
||||
const calledWith: unknown[] = []; |
||||
const getValue = createCachedDataProvider({ |
||||
// We need noticeable cache latency for deterministic behavior, so that it will always throttle
|
||||
// instead of switching between throttling and caching at random
|
||||
cacheStorage: createKeyValueStorage<CacheEntry<ValueType>>(10), |
||||
getNewValue: (key: ValueType) => { |
||||
const callNumber = calledWith.length; |
||||
calledWith.push(key); |
||||
return Promise.resolve({ ...key, callNumber }); |
||||
}, |
||||
ttlMs: 0, |
||||
}); |
||||
|
||||
expect( |
||||
await Promise.all([ |
||||
getValue({ key: 1 }), |
||||
getValue({ key: 2 }), |
||||
getValue({ key: 1 }), |
||||
getValue({ key: 1 }), |
||||
getValue({ key: 3 }), |
||||
]), |
||||
).toEqual([ |
||||
{ key: 1, callNumber: 0 }, |
||||
{ key: 2, callNumber: 1 }, |
||||
{ key: 1, callNumber: 0 }, |
||||
{ key: 1, callNumber: 0 }, |
||||
{ key: 3, callNumber: 2 }, |
||||
]); |
||||
|
||||
expect(calledWith).toEqual([{ key: 1 }, { key: 2 }, { key: 3 }]); |
||||
}); |
||||
}); |
@ -0,0 +1,41 @@ |
||||
import { throttle } from '../utils/throttle'; |
||||
import { StringifiableValue, createSerializer } from '../utils/serializer'; |
||||
import { ClearableKeyValueStorage } from './types'; |
||||
|
||||
type DataProvider<TKey, TValue> = (key: TKey) => Promise<TValue>; |
||||
|
||||
export type CacheEntry<TValue> = { |
||||
expirationDate: number; |
||||
cachedValue: TValue; |
||||
}; |
||||
|
||||
export const createCachedDataProvider = < |
||||
TKey extends StringifiableValue, |
||||
TValue, |
||||
>({ |
||||
cacheStorage, |
||||
getNewValue, |
||||
ttlMs, |
||||
}: { |
||||
cacheStorage: ClearableKeyValueStorage<string, CacheEntry<TValue>>; |
||||
getNewValue: DataProvider<TKey, TValue>; |
||||
ttlMs: number; |
||||
}) => { |
||||
const keySerializer = createSerializer<TKey>(); |
||||
const unsafeGet = async (key: TKey) => { |
||||
const cacheEntry = await cacheStorage.get(keySerializer.stringify(key)); |
||||
if (cacheEntry.found && cacheEntry.value.expirationDate >= Date.now()) { |
||||
return cacheEntry.value.cachedValue; |
||||
} |
||||
|
||||
const newValue = await getNewValue(key); |
||||
await cacheStorage.set(keySerializer.stringify(key), { |
||||
expirationDate: Date.now() + ttlMs, |
||||
cachedValue: newValue, |
||||
}); |
||||
|
||||
return newValue; |
||||
}; |
||||
|
||||
return throttle(unsafeGet); |
||||
}; |
@ -0,0 +1,45 @@ |
||||
import { sleep } from '../utils/eventLoop'; |
||||
import { StringifiableValue, createSerializer } from '../utils/serializer'; |
||||
import { ClearableKeyValueStorage } from './types'; |
||||
|
||||
export const createKeyValueStorage = <TValue extends StringifiableValue>( |
||||
latencyMs: number, |
||||
) => { |
||||
const withSimulatedLatency = async <TResult>( |
||||
f: () => Promise<TResult>, |
||||
): Promise<TResult> => { |
||||
// TODO: do some CPU load here, to make performance similar to real DBs, for performance testing
|
||||
await sleep(latencyMs); |
||||
// TODO: do some additional CPU load here
|
||||
return f(); |
||||
}; |
||||
|
||||
const serializer = createSerializer<TValue>(); |
||||
const storage = new Map<string, string>(); |
||||
return { |
||||
get: (key: string) => |
||||
withSimulatedLatency(() => |
||||
Promise.resolve( |
||||
storage.has(key) |
||||
? { |
||||
found: true as const, |
||||
// `storage.get(key)` is guaranteed to return value, because we just checked that it's there
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value: serializer.parse(storage.get(key)!), |
||||
} |
||||
: { |
||||
found: false as const, |
||||
}, |
||||
), |
||||
), |
||||
set: (key: string, value: TValue) => |
||||
withSimulatedLatency(() => |
||||
Promise.resolve( |
||||
void storage.set(key, serializer.stringify(value)), |
||||
), |
||||
), |
||||
clear: () => |
||||
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
|
||||
withSimulatedLatency(() => Promise.resolve(storage.clear())), |
||||
} as ClearableKeyValueStorage<string, TValue>; |
||||
}; |
@ -0,0 +1,11 @@ |
||||
export type KeyValueStorage<TKey, TValue> = { |
||||
get(key: TKey): Promise<{ found: true; value: TValue } | { found: false }>; |
||||
set(key: TKey, value: TValue): Promise<void>; |
||||
}; |
||||
|
||||
export type ClearableKeyValueStorage<TKey, TValue> = KeyValueStorage< |
||||
TKey, |
||||
TValue |
||||
> & { |
||||
clear(): Promise<void>; |
||||
}; |
@ -0,0 +1,18 @@ |
||||
// Type that survives being serialized to json and back
|
||||
export type StringifiableValue = |
||||
| string |
||||
| number |
||||
| boolean |
||||
| null |
||||
| undefined |
||||
| StringifiableValue[] |
||||
| { |
||||
[key: string]: StringifiableValue; |
||||
}; |
||||
|
||||
// To be used instead of JSON.stringify / JSON.parse everywhere, in order to ensure that values are of stringifiable type
|
||||
export const createSerializer = <TValue extends StringifiableValue>() => |
||||
JSON as { |
||||
stringify(value: TValue): string; |
||||
parse(serialized: string): TValue; |
||||
}; |
Loading…
Reference in new issue