From adbb5edb4be8778d47e1c42ac8dca4c69862172c Mon Sep 17 00:00:00 2001 From: Inga Date: Sun, 22 Oct 2023 20:46:56 +0000 Subject: [PATCH] implemented cache --- src/storage/cache.spec.ts | 144 ++++++++++++++++++++++++++++++++++++++ src/storage/cache.ts | 41 +++++++++++ src/storage/inMemoryDB.ts | 45 ++++++++++++ src/storage/types.ts | 11 +++ src/utils/serializer.ts | 18 +++++ src/utils/throttle.ts | 6 +- 6 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/storage/cache.spec.ts create mode 100644 src/storage/cache.ts create mode 100644 src/storage/inMemoryDB.ts create mode 100644 src/storage/types.ts create mode 100644 src/utils/serializer.ts diff --git a/src/storage/cache.spec.ts b/src/storage/cache.spec.ts new file mode 100644 index 0000000..acf74cf --- /dev/null +++ b/src/storage/cache.spec.ts @@ -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; + +describe('createCachedDataProvider', () => { + it('always fetches remote data when ttl is zero, for sequential calls', async () => { + const calledWith: unknown[] = []; + const getValue = createCachedDataProvider({ + cacheStorage: createKeyValueStorage>(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>(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>(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>(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>(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 }]); + }); +}); diff --git a/src/storage/cache.ts b/src/storage/cache.ts new file mode 100644 index 0000000..04eeacb --- /dev/null +++ b/src/storage/cache.ts @@ -0,0 +1,41 @@ +import { throttle } from '../utils/throttle'; +import { StringifiableValue, createSerializer } from '../utils/serializer'; +import { ClearableKeyValueStorage } from './types'; + +type DataProvider = (key: TKey) => Promise; + +export type CacheEntry = { + expirationDate: number; + cachedValue: TValue; +}; + +export const createCachedDataProvider = < + TKey extends StringifiableValue, + TValue, +>({ + cacheStorage, + getNewValue, + ttlMs, +}: { + cacheStorage: ClearableKeyValueStorage>; + getNewValue: DataProvider; + ttlMs: number; +}) => { + const keySerializer = createSerializer(); + 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); +}; diff --git a/src/storage/inMemoryDB.ts b/src/storage/inMemoryDB.ts new file mode 100644 index 0000000..432d3a6 --- /dev/null +++ b/src/storage/inMemoryDB.ts @@ -0,0 +1,45 @@ +import { sleep } from '../utils/eventLoop'; +import { StringifiableValue, createSerializer } from '../utils/serializer'; +import { ClearableKeyValueStorage } from './types'; + +export const createKeyValueStorage = ( + latencyMs: number, +) => { + const withSimulatedLatency = async ( + f: () => Promise, + ): Promise => { + // 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(); + const storage = new Map(); + 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; +}; diff --git a/src/storage/types.ts b/src/storage/types.ts new file mode 100644 index 0000000..57a7d45 --- /dev/null +++ b/src/storage/types.ts @@ -0,0 +1,11 @@ +export type KeyValueStorage = { + get(key: TKey): Promise<{ found: true; value: TValue } | { found: false }>; + set(key: TKey, value: TValue): Promise; +}; + +export type ClearableKeyValueStorage = KeyValueStorage< + TKey, + TValue +> & { + clear(): Promise; +}; diff --git a/src/utils/serializer.ts b/src/utils/serializer.ts new file mode 100644 index 0000000..1474fbb --- /dev/null +++ b/src/utils/serializer.ts @@ -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 = () => + JSON as { + stringify(value: TValue): string; + parse(serialized: string): TValue; + }; diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts index 5d7a80a..4db2f03 100644 --- a/src/utils/throttle.ts +++ b/src/utils/throttle.ts @@ -1,4 +1,5 @@ import { nextTick } from './eventLoop'; +import { StringifiableValue, createSerializer } from './serializer'; /** * Function like this probably already exists on npm, or maybe it can be constructed from lodash tools etc. @@ -20,12 +21,13 @@ import { nextTick } from './eventLoop'; * @returns Wrapped function, such that if it is called with the same arguments as some of the previous calls * that did not yet resolve, it will return the previous promise rather than invoke `f` again. */ -export const throttle = ( +export const throttle = ( f: (...args: TArgs) => Promise, ) => { + const argsSerializer = createSerializer(); const promises = new Map>(); return (...args: TArgs) => { - const promiseKey = JSON.stringify(args); + const promiseKey = argsSerializer.stringify(args); if (!promises.has(promiseKey)) { promises.set( promiseKey,