Skip to content

Commit

Permalink
feat(perf): [halt] prevent redundant async cache gets
Browse files Browse the repository at this point in the history
  • Loading branch information
uladkasach committed Sep 1, 2024
1 parent a832241 commit 823ad69
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 37 deletions.
9 changes: 7 additions & 2 deletions src/logic/options/getCacheFromCacheOptionOrFromForKeyArgs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SimpleInMemoryCache } from 'simple-in-memory-cache';
import { isAFunction } from 'type-fns';

import { SimpleCache } from '../../domain/SimpleCache';
Expand Down Expand Up @@ -29,9 +30,13 @@ export const getCacheFromCacheOptionOrFromForKeyArgs = <
trigger,
}: {
args: { forKey: string; cache?: C } | { forInput: Parameters<L> };
options: { cache: WithSimpleCachingCacheOption<Parameters<L>, C> };
options: {
cache:
| WithSimpleCachingCacheOption<Parameters<L>, C> // for the output.cache
| WithSimpleCachingCacheOption<Parameters<L>, SimpleInMemoryCache<any>>; // for the dedupe.cache
};
trigger: WithExtendableCachingTrigger;
}): C => {
}): C | SimpleInMemoryCache<any> => {
// if the args have the forInput property, then we can grab the cache like normal
if (hasForInputProperty(args))
return getCacheFromCacheOption({
Expand Down
39 changes: 31 additions & 8 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 {
getDedupeCacheOptionFromCacheInput,
getOutputCacheOptionFromCacheInput,
withSimpleCachingAsync,
WithSimpleCachingAsyncOptions,
Expand Down Expand Up @@ -152,13 +153,22 @@ export const withExtendableCachingAsync = <
L,
C
>['invalidate'] = async (args) => {
// define how to get the cache, with support for `forKey` input instead of full input
const cache = getCacheFromCacheOptionOrFromForKeyArgs({
// lookup the output cache
const cacheOutput = getCacheFromCacheOptionOrFromForKeyArgs({
args,
options: { cache: getOutputCacheOptionFromCacheInput(options.cache) },
trigger: WithExtendableCachingTrigger.INVALIDATE,
});

// lookup the dedupe cache
const cacheDedupe = getCacheFromCacheOptionOrFromForKeyArgs({
args,
options: {
cache: getDedupeCacheOptionFromCacheInput(options.cache),
},
trigger: WithExtendableCachingTrigger.INVALIDATE,
});

// define the key, with support for `forKey` input instead of `forInput`
const serializeKey =
options.serialize?.key ?? defaultKeySerializationMethod;
Expand All @@ -167,17 +177,27 @@ export const withExtendableCachingAsync = <
: args.forKey;

// set undefined into the cache for this key, to invalidate the cached value
await cache.set(key, undefined);
await cacheOutput.set(key, undefined);
await cacheDedupe.set(key, undefined);
};

const update: LogicWithExtendableCachingAsync<L, C>['update'] = async (
args,
) => {
// define how to get the cache, with support for `forKey` input instead of full input
const cache = getCacheFromCacheOptionOrFromForKeyArgs({
// lookup the output cache
const cacheOutput = getCacheFromCacheOptionOrFromForKeyArgs({
args,
options: { cache: getOutputCacheOptionFromCacheInput(options.cache) },
trigger: WithExtendableCachingTrigger.UPDATE,
trigger: WithExtendableCachingTrigger.INVALIDATE,
});

// lookup the dedupe cache
const cacheDedupe = getCacheFromCacheOptionOrFromForKeyArgs({
args,
options: {
cache: getDedupeCacheOptionFromCacheInput(options.cache),
},
trigger: WithExtendableCachingTrigger.INVALIDATE,
});

// define the key, with support for `forKey` input instead of `forInput`
Expand All @@ -188,7 +208,7 @@ export const withExtendableCachingAsync = <
: args.forKey;

// deserialize the cached value
const cachedValue = await cache.get(key);
const cachedValue = await cacheOutput.get(key);
const deserializeValue =
options.deserialize?.value ?? defaultValueDeserializationMethod;
const deserializedCachedOutput =
Expand All @@ -205,7 +225,10 @@ export const withExtendableCachingAsync = <
const serializedNewValue = serializeValue(newValue);

// set the new value for this key
await cache.set(key, serializedNewValue, {
await cacheOutput.set(key, serializedNewValue, {
secondsUntilExpiration: options.secondsUntilExpiration,
});
await cacheDedupe.set(key, serializedNewValue, {
secondsUntilExpiration: options.secondsUntilExpiration,
});
};
Expand Down
55 changes: 55 additions & 0 deletions src/logic/wrappers/withSimpleCachingAsync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
} from '../../__test_assets__/createExampleCache';
import { withSimpleCachingAsync } from './withSimpleCachingAsync';

jest.setTimeout(60 * 1000);

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

describe('withSimpleCachingAsync', () => {
Expand Down Expand Up @@ -529,4 +531,57 @@ describe('withSimpleCachingAsync', () => {
});
});
});
describe('performance', () => {
it('should prevent redundant async-cache.gets, to avoid expensive disk.read/api.call latencies, in favor of in-memory-cache.gets', async () => {
const store: Record<string, string | undefined> = {};
const diskReads = [];
const cache: SimpleAsyncCache<string> = {
set: async (key: string, value: string | undefined) => {
store[key] = value;
},
get: async (key: string) => {
diskReads.push(key);
await sleep(1500); // act like it takes a while for the cache to resolve => async cache reads are typically expensive
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 sequence
await callApi({ name: 'casey' });
await callApi({ name: 'katya' });
await callApi({ name: 'sonya' });
await callApi({ name: 'casey' });
await callApi({ name: 'katya' });
await callApi({ name: 'sonya' });
await callApi({ name: 'casey' });
await callApi({ name: 'katya' });
await callApi({ name: 'sonya' });
await callApi({ name: 'casey' });
await callApi({ name: 'katya' });
await callApi({ name: 'sonya' });
await callApi({ name: 'casey' });
await callApi({ name: 'katya' });
await callApi({ name: 'sonya' });

// check that "api" was only called thrice (once per name)
expect(apiCalls.length).toEqual(3);

// check that the disk was only read from 3*2 (2x per name, since initial cache.miss results in 2 gets), to prevent redundant disk reads that needlessly add latency; after first disk.get, we can subsequently .get from memory
expect(diskReads.length).toEqual(6);
});
});
});
39 changes: 12 additions & 27 deletions src/logic/wrappers/withSimpleCachingAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
KeySerializationMethod,
noOp,
} from '../serde/defaults';
import { withExtendableCaching } from './withExtendableCaching';
import { withSimpleCaching } from './withSimpleCaching';

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

Expand Down Expand Up @@ -114,7 +114,7 @@ export const getOutputCacheOptionFromCacheInput = <
/**
* method to get the output cache option chosen by the user from the cache input
*/
const getDeduplicationCacheOptionFromCacheInput = <
export const getDedupeCacheOptionFromCacheInput = <
/**
* the logic we are caching the responses for
*/
Expand All @@ -125,11 +125,11 @@ const getDeduplicationCacheOptionFromCacheInput = <
C extends SimpleCache<any>,
>(
cacheInput: WithSimpleCachingAsyncOptions<L, C>['cache'],
): SimpleInMemoryCache<any> =>
): WithSimpleCachingCacheOption<Parameters<L>, 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)
defaultSecondsUntilExpiration: 60, // todo: define the default expiration seconds based on output cache default expiration seconds
});

/**
Expand Down Expand Up @@ -210,29 +210,14 @@ export const withSimpleCachingAsync = <
return output;
}) as L;

// 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: getDeduplicationCacheOptionFromCacheInput(cacheOption),
serialize: {
key: serializeKey,
},
});

// define a function which the user will run which kicks off the result + invalidates the in-memory cache promise as soon as it finishes
const logicWithAsyncCachingAndInMemoryRequestDeduplication = async (
...args: Parameters<L>
): Promise<ReturnType<L>> => {
// start executing the request w/ async caching + sync caching
const promiseResult = execute(...args);

// ensure that after the promise resolves, we remove it from the cache (so that unique subsequent requests can still be made)
const promiseResultAfterInvalidation = promiseResult
.finally(() => invalidate({ forInput: args }))
.then(() => promiseResult);

// return the result after invalidation
return promiseResultAfterInvalidation;
};
// define a function which the user will run which kicks off the result
const logicWithAsyncCachingAndInMemoryRequestDeduplication =
withSimpleCaching(logicWithAsyncCaching, {
cache: getDedupeCacheOptionFromCacheInput(cacheOption),
serialize: {
key: serializeKey,
},
});

// return the function w/ async caching and sync-in-memory-request-deduplication
return logicWithAsyncCachingAndInMemoryRequestDeduplication as L;
Expand Down

0 comments on commit 823ad69

Please sign in to comment.