Last active
December 6, 2023 01:39
-
-
Save nrkn/e6d3f7fe526713c9ccc570ad9f1e1ecd to your computer and use it in GitHub Desktop.
Try to prevent cache stampedes when using promises
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// try to prevent cache stampedes | |
// | |
// if you don't do this, then when we get a lot of simultaneous requests for the | |
// same thing and it's not cached yet, every single request will think it has | |
// to generate the resource, whereas using this makes it so that the very first | |
// one triggers the work, and the others either wait on the same promise, or get | |
// served the cached version if it's already resolved | |
// | |
// the effect of having every request try to generate something is to overload | |
// the server and cause weird side effects, like files not being read or written | |
// properly | |
// | |
// https://en.wikipedia.org/wiki/Cache_stampede | |
export const cachedPromise = <K, T>( | |
// the actual function that gets the data | |
get: (key: K) => Promise<T>, | |
// should return true if the current key is no longer valid, eg for TTL where | |
// the item is now too old etc | |
// | |
// some items never need to be invalidated, so this fn is optional, omitting | |
// it creates a cache that persists for the lifecycle of the app, so don't | |
// use it with ephemeral data or the cache will eat the server's memory | |
isInvalid?: (key: K, current: K) => boolean | |
) => { | |
const cache = new Map<K, Promise<T>>() | |
// we invalidate previous keys, using the current key as a comparator | |
const invalidate = (current: K) => { | |
if( isInvalid === undefined ) return | |
const keys = cache.keys() | |
for (const key of keys) { | |
if (isInvalid(key, current)) { | |
console.debug('cachedPromise', 'cache expired', key) | |
cache.delete(key) | |
} | |
} | |
} | |
// return the existing cached promise or create it if it doesn't exist | |
const getData = async (key: K) => { | |
// invalidate if no longer valid, eg older keys when doing TTL etc | |
invalidate(key) | |
const data = cache.get(key) | |
// sweet - this is what we want to see in the logs as much as possible | |
if (data !== undefined) { | |
console.debug('cachedPromise', 'cache hit', key) | |
return data | |
} | |
// misses can't be helped though! expect them the first time we access | |
console.debug('cachedPromise', 'cache miss', key) | |
// ok, looks like we have to do the thing for the first time | |
const promise = get(key).then( | |
result => { | |
// once resolved, replace the promise with the result | |
cache.set(key, Promise.resolve(result)) | |
console.debug('cachedPromise', 'promise resolved', key) | |
return result | |
} | |
).catch( | |
err => { | |
// if rejected, remove the promise from the cache | |
cache.delete(key) | |
console.warn('cachedPromise', 'promise rejected', key) | |
throw err | |
} | |
) | |
// cache the unresolved promise, subsequent callers will either get this or | |
// the resolved promise depending on timing | |
cache.set(key, promise) | |
return promise | |
} | |
return getData | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment