diff --git a/src/logic/options/getCacheFromCacheOptionOrFromForKeyArgs.ts b/src/logic/options/getCacheFromCacheOptionOrFromForKeyArgs.ts index 1736f4a..53db486 100644 --- a/src/logic/options/getCacheFromCacheOptionOrFromForKeyArgs.ts +++ b/src/logic/options/getCacheFromCacheOptionOrFromForKeyArgs.ts @@ -1,3 +1,4 @@ +import { SimpleInMemoryCache } from 'simple-in-memory-cache'; import { isAFunction } from 'type-fns'; import { SimpleCache } from '../../domain/SimpleCache'; @@ -29,9 +30,13 @@ export const getCacheFromCacheOptionOrFromForKeyArgs = < trigger, }: { args: { forKey: string; cache?: C } | { forInput: Parameters }; - options: { cache: WithSimpleCachingCacheOption, C> }; + options: { + cache: + | WithSimpleCachingCacheOption, C> // for the output.cache + | WithSimpleCachingCacheOption, SimpleInMemoryCache>; // for the dedupe.cache + }; trigger: WithExtendableCachingTrigger; -}): C => { +}): C | SimpleInMemoryCache => { // if the args have the forInput property, then we can grab the cache like normal if (hasForInputProperty(args)) return getCacheFromCacheOption({ diff --git a/src/logic/wrappers/withExtendableCachingAsync.ts b/src/logic/wrappers/withExtendableCachingAsync.ts index 8649d49..1db50a5 100644 --- a/src/logic/wrappers/withExtendableCachingAsync.ts +++ b/src/logic/wrappers/withExtendableCachingAsync.ts @@ -8,6 +8,7 @@ import { defaultValueSerializationMethod, } from '../serde/defaults'; import { + getDedupeCacheOptionFromCacheInput, getOutputCacheOptionFromCacheInput, withSimpleCachingAsync, WithSimpleCachingAsyncOptions, @@ -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; @@ -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['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` @@ -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 = @@ -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, }); }; diff --git a/src/logic/wrappers/withSimpleCachingAsync.test.ts b/src/logic/wrappers/withSimpleCachingAsync.test.ts index 921e70a..b2255dd 100644 --- a/src/logic/wrappers/withSimpleCachingAsync.test.ts +++ b/src/logic/wrappers/withSimpleCachingAsync.test.ts @@ -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', () => { @@ -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 = {}; + const diskReads = []; + const cache: SimpleAsyncCache = { + 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); + }); + }); }); diff --git a/src/logic/wrappers/withSimpleCachingAsync.ts b/src/logic/wrappers/withSimpleCachingAsync.ts index 6e4f5a4..e17293a 100644 --- a/src/logic/wrappers/withSimpleCachingAsync.ts +++ b/src/logic/wrappers/withSimpleCachingAsync.ts @@ -14,7 +14,7 @@ import { KeySerializationMethod, noOp, } from '../serde/defaults'; -import { withExtendableCaching } from './withExtendableCaching'; +import { withSimpleCaching } from './withSimpleCaching'; export type AsyncLogic = (...args: any[]) => Promise; @@ -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 */ @@ -125,11 +125,11 @@ const getDeduplicationCacheOptionFromCacheInput = < C extends SimpleCache, >( cacheInput: WithSimpleCachingAsyncOptions['cache'], -): SimpleInMemoryCache => +): WithSimpleCachingCacheOption, 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) + defaultSecondsUntilExpiration: 60, // todo: define the default expiration seconds based on output cache default expiration seconds }); /** @@ -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 - ): Promise> => { - // 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;