Skip to content

Commit

Permalink
fix(async): enable deduplication cache input to with-async-cache
Browse files Browse the repository at this point in the history
  • Loading branch information
uladkasach committed Aug 11, 2023
1 parent 04a1b1b commit 2f8ad46
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 10 deletions.
5 changes: 3 additions & 2 deletions src/logic/wrappers/withExtendableCachingAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
defaultValueSerializationMethod,
} from '../serde/defaults';
import {
getOutputCacheOptionFromCacheInput,
withSimpleCachingAsync,
WithSimpleCachingAsyncOptions,
} from './withSimpleCachingAsync';
Expand Down Expand Up @@ -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,
});

Expand All @@ -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,
});

Expand Down
49 changes: 49 additions & 0 deletions src/logic/wrappers/withSimpleCachingAsync.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createCache } from 'simple-in-memory-cache';

import { SimpleAsyncCache } from '../..';
import {
createExampleAsyncCache,
Expand Down Expand Up @@ -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<string, string | undefined> = {};
const cache: SimpleAsyncCache<string> = {
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();

Expand Down
78 changes: 70 additions & 8 deletions src/logic/wrappers/withSimpleCachingAsync.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,20 +14,43 @@ import {
} from '../serde/defaults';
import { withExtendableCaching } from './withExtendableCaching';

export type AsyncLogic = (...args: any[]) => Promise<any>;

/**
* options to configure caching for use with-simple-caching
*/
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<any>,
> {
cache: WithSimpleCachingCacheOption<Parameters<L>, C>;
/**
* the cache to persist outputs
*/
cache:
| WithSimpleCachingCacheOption<Parameters<L>, C>
| {
/**
* the cache to cache output to
*/
output: WithSimpleCachingCacheOption<Parameters<L>, 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<any>;
};
serialize?: {
key?: KeySerializationMethod<Parameters<L>>;
value?: (
Expand All @@ -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<any>,
>(
cacheInput: WithSimpleCachingAsyncOptions<L, C>['cache'],
): WithSimpleCachingCacheOption<Parameters<L>, 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<any>,
>(
cacheInput: WithSimpleCachingAsyncOptions<L, C>['cache'],
): SimpleInMemoryCache<any> =>
'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
*
Expand All @@ -53,7 +114,7 @@ export const withSimpleCachingAsync = <
/**
* the logic we are caching the responses for
*/
L extends (...args: any[]) => Promise<any>,
L extends AsyncLogic,
/**
* the type of cache being used
*/
Expand All @@ -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<ReturnType<C['get']>> = await cache.get(key);
Expand Down Expand Up @@ -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,
},
Expand Down

0 comments on commit 2f8ad46

Please sign in to comment.