From 2f8ad4625107704acb6070fa77b5909221ca4231 Mon Sep 17 00:00:00 2001 From: Ulad Kasach Date: Thu, 10 Aug 2023 17:37:33 -0700 Subject: [PATCH] fix(async): enable deduplication cache input to with-async-cache --- .../wrappers/withExtendableCachingAsync.ts | 5 +- .../wrappers/withSimpleCachingAsync.test.ts | 49 ++++++++++++ src/logic/wrappers/withSimpleCachingAsync.ts | 78 +++++++++++++++++-- 3 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/logic/wrappers/withExtendableCachingAsync.ts b/src/logic/wrappers/withExtendableCachingAsync.ts index 927cb77..8649d49 100644 --- a/src/logic/wrappers/withExtendableCachingAsync.ts +++ b/src/logic/wrappers/withExtendableCachingAsync.ts @@ -8,6 +8,7 @@ import { defaultValueSerializationMethod, } from '../serde/defaults'; import { + getOutputCacheOptionFromCacheInput, withSimpleCachingAsync, WithSimpleCachingAsyncOptions, } from './withSimpleCachingAsync'; @@ -154,7 +155,7 @@ export const withExtendableCachingAsync = < // define how to get the cache, with support for `forKey` input instead of full input const cache = getCacheFromCacheOptionOrFromForKeyArgs({ args, - options, + options: { cache: getOutputCacheOptionFromCacheInput(options.cache) }, trigger: WithExtendableCachingTrigger.INVALIDATE, }); @@ -175,7 +176,7 @@ export const withExtendableCachingAsync = < // define how to get the cache, with support for `forKey` input instead of full input const cache = getCacheFromCacheOptionOrFromForKeyArgs({ args, - options, + options: { cache: getOutputCacheOptionFromCacheInput(options.cache) }, trigger: WithExtendableCachingTrigger.UPDATE, }); diff --git a/src/logic/wrappers/withSimpleCachingAsync.test.ts b/src/logic/wrappers/withSimpleCachingAsync.test.ts index ccc8550..921e70a 100644 --- a/src/logic/wrappers/withSimpleCachingAsync.test.ts +++ b/src/logic/wrappers/withSimpleCachingAsync.test.ts @@ -1,3 +1,5 @@ +import { createCache } from 'simple-in-memory-cache'; + import { SimpleAsyncCache } from '../..'; import { createExampleAsyncCache, @@ -155,6 +157,53 @@ describe('withSimpleCachingAsync', () => { 'number', ); // now prove that the value saved into the cache for this name is definetly not a promise }); + it('should deduplicate parallel requests in memory via the passed in in-memory cache if one was passed in', async () => { + const store: Record = {}; + const cache: SimpleAsyncCache = { + set: async (key: string, value: string | undefined) => { + store[key] = value; + }, + get: async (key: string) => { + await sleep(1500); // act like it takes a while for the cache to resolve + return store[key]; + }, + }; + + // define an example fn + const apiCalls = []; + const deduplicationCache = createCache(); + const callApi = (args: { name: string }) => + // note that this function instantiates a new wrapper each time -> requiring the deduplication cache to be passed in + withSimpleCachingAsync( + async ({ name }: { name: string }) => { + apiCalls.push(name); + await sleep(100); + return Math.random(); + }, + { cache: { output: cache, deduplication: deduplicationCache } }, + )(args); + + // call the fn a few times, in parallel + const [result1, result2, result3, result4] = await Promise.all([ + callApi({ name: 'casey' }), + callApi({ name: 'katya' }), + callApi({ name: 'casey' }), + callApi({ name: 'katya' }), + ]); + + // check that the response is the same each time the input is the same + expect(result1).toEqual(result3); + expect(result2).toEqual(result4); + + // check that "api" was only called twice (once per name) + expect(apiCalls.length).toEqual(2); + + // check that the value in the cache is not the promise, but the value itself + expect(typeof Promise.resolve(821)).toEqual('object'); // prove that a promise to resolve a number has a typeof object + expect(typeof store[JSON.stringify([{ name: 'casey' }])]).toEqual( + 'number', + ); // now prove that the value saved into the cache for this name is definetly not a promise + }); it('should be possible to catch an error which was rejected by a promise set to the cache in an async cache which awaited the value onSet', async () => { const { cache, store } = createExampleAsyncCache(); diff --git a/src/logic/wrappers/withSimpleCachingAsync.ts b/src/logic/wrappers/withSimpleCachingAsync.ts index cbf9bae..f8ef5bb 100644 --- a/src/logic/wrappers/withSimpleCachingAsync.ts +++ b/src/logic/wrappers/withSimpleCachingAsync.ts @@ -1,4 +1,4 @@ -import { createCache } from 'simple-in-memory-cache'; +import { createCache, SimpleInMemoryCache } from 'simple-in-memory-cache'; import { isNotUndefined, NotUndefined } from 'type-fns'; import { SimpleCache } from '../../domain/SimpleCache'; @@ -14,6 +14,8 @@ import { } from '../serde/defaults'; import { withExtendableCaching } from './withExtendableCaching'; +export type AsyncLogic = (...args: any[]) => Promise; + /** * options to configure caching for use with-simple-caching */ @@ -21,13 +23,34 @@ export interface WithSimpleCachingAsyncOptions< /** * the logic we are caching the responses for */ - L extends (...args: any) => any, + L extends AsyncLogic, /** * the type of cache being used */ C extends SimpleCache, > { - cache: WithSimpleCachingCacheOption, C>; + /** + * the cache to persist outputs + */ + cache: + | WithSimpleCachingCacheOption, C> + | { + /** + * the cache to cache output to + */ + output: WithSimpleCachingCacheOption, C>; + + /** + * the cache to use for parallel in memory request deduplication + * + * note + * - by default, this method will use its own in-memory cache for deduplication + * - if required, you can pass in your own in-memory cache to use + * - for example, if you're instantiating the wrapper on each execution of your logic, instead of globally + * - important: if passing in your own, make sure that the cache time is at least as long as your longest resolving promise (e.g., 15min) ⚠️ + */ + deduplication: SimpleInMemoryCache; + }; serialize?: { key?: KeySerializationMethod>; value?: ( @@ -42,6 +65,44 @@ export interface WithSimpleCachingAsyncOptions< secondsUntilExpiration?: number; } +/** + * method to get the output cache option chosen by the user from the cache input + */ +export const getOutputCacheOptionFromCacheInput = < + /** + * the logic we are caching the responses for + */ + L extends AsyncLogic, + /** + * the type of cache being used + */ + C extends SimpleCache, +>( + cacheInput: WithSimpleCachingAsyncOptions['cache'], +): WithSimpleCachingCacheOption, C> => + 'output' in cacheInput ? cacheInput.output : cacheInput; + +/** + * method to get the output cache option chosen by the user from the cache input + */ +const getDeduplicationCacheOptionFromCacheInput = < + /** + * the logic we are caching the responses for + */ + L extends AsyncLogic, + /** + * the type of cache being used + */ + C extends SimpleCache, +>( + cacheInput: WithSimpleCachingAsyncOptions['cache'], +): SimpleInMemoryCache => + 'deduplication' in cacheInput + ? cacheInput.deduplication + : createCache({ + defaultSecondsUntilExpiration: 15 * 60, // support deduplicating requests that take up to 15 minutes to resolve, by default (note: we remove the promise as soon as it resolves through "serialize" method below) + }); + /** * a wrapper which adds asynchronous caching to asynchronous logic * @@ -53,7 +114,7 @@ export const withSimpleCachingAsync = < /** * the logic we are caching the responses for */ - L extends (...args: any[]) => Promise, + L extends AsyncLogic, /** * the type of cache being used */ @@ -78,7 +139,10 @@ export const withSimpleCachingAsync = < const key = serializeKey({ forInput: args }); // define cache based on options - const cache = getCacheFromCacheOption({ forInput: args, cacheOption }); + const cache = getCacheFromCacheOption({ + forInput: args, + cacheOption: getOutputCacheOptionFromCacheInput(cacheOption), + }); // see if its already cached const cachedValue: Awaited> = await cache.get(key); @@ -110,9 +174,7 @@ export const withSimpleCachingAsync = < // wrap the logic with extended sync caching, to ensure that duplicate requests resolve the same promise from in-memory (rather than each getting a promise to check the async cache + operate separately) const { execute, invalidate } = withExtendableCaching(logicWithAsyncCaching, { - cache: createCache({ - defaultSecondsUntilExpiration: 15 * 60, // support deduplicating requests that take up to 15 minutes to resolve, by default (note: we remove the promise as soon as it resolves through "serialize" method below) - }), + cache: getDeduplicationCacheOptionFromCacheInput(cacheOption), serialize: { key: serializeKey, },