Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(perf): [halt] prevent redundant async cache gets #33

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading