Skip to content

Instantly share code, notes, and snippets.

@rigwild
Last active April 28, 2024 17:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rigwild/7e26a43c5ddf800e7c6691fcbf13c3f4 to your computer and use it in GitHub Desktop.
Save rigwild/7e26a43c5ddf800e7c6691fcbf13c3f4 to your computer and use it in GitHub Desktop.
Cache a function locally and automatically - With a lockable cache version if using multi threading

Using withCache

const myFriends: CacheMap<{ name: string; age: number }> = {}

async function fetchMyFriendsFromCity(fromCity: string) {
  // This is to preserve the name of the function in the stack trace
  const fetchMyFriendsFromCity = async () => {
    console.log('Fetching my friends from the database...')
    return (await db.query('SELECT * FROM friends WHERE city = ?', [fromCity])).rows
  }

  return withCache({
    cacheMap: myFriends,
    cacheExpirationTimeMs: 30 * 1000, // 30 seconds cache
    cacheKey: fromCity,
    fetchFunctionIfCacheMiss: fetchMyFriendsFromCity,
})

console.log(await fetchMyFriendsFromCity('New York')) // Fetches from the database
console.log(await fetchMyFriendsFromCity('New York')) // Reads from the cache

await sleep(45 * 1000) // Wait for the cache to expire

console.log(await fetchMyFriendsFromCity('New York')) // Fetches from the database

Using withCacheLockable

const myFriends: CacheLockableMap<{ name: string; age: number }> = {}

async function fetchMyFriendsFromCity(fromCity: string) {
  // This is to preserve the name of the function in the stack trace
  const fetchMyFriendsFromCity = async () => {
    console.log('Fetching my friends from the database...')
    await new Promise(resolve => setTimeout(resolve, 3000)) // Simulate a slow database query
    return (await db.query('SELECT * FROM friends WHERE city = ?', [fromCity])).rows
  }

  return withCacheLockable({
    cacheMap: myFriends,
    cacheExpirationTimeMs: 30 * 1000, // 30 seconds cache
    cacheMaxWaitForResultIfLockedMs: 15 * 1000, // 15 seconds
    cacheKey: fromCity,
    fetchFunctionIfCacheMiss: fetchMyFriendsFromCity,
})

const taskA = fetchMyFriendsFromCity('New York') // Fetches from the database with lock
const taskB = fetchMyFriendsFromCity('New York') // Waiting for taskA to finish then reads from the cache

await taskA
await taskB // Will be instant because it reads from the cache

console.log(taskA)
console.log(taskB) // Same result as taskA


console.log(await fetchMyFriendsFromCity('New York')) // Data is already in cache, reads from the cache

await sleep(45 * 1000) // Wait for the cache to expire

console.log(await fetchMyFriendsFromCity('New York')) // Fetches from the database
// use `withCacheHelper.ts` version tbh, curried function are hard to debug
// ---
/**
* Create a function that caches the result of a fetch function for a given time.
*
* @param cacheExpirationTimeMs Time in milliseconds after which the cache will expire
* @returns A function that will cache the result of the fetch function for the given time
*
* @example
* ```ts
* const fetchFunction = async () => {
* console.log('Fetching...')
* await new Promise(resolve => setTimeout(resolve, 3000))
* return 'result'
* }
*
* const cacheFunction = createCacheFunction(10_000)
*
* console.log(await cacheFunction('key', fetchFunction)) // Will print 'Fetching...' and 'result'
* console.log(await cacheFunction('key', fetchFunction)) // Will print 'result'
* await sleep(15_000)
* console.log(await cacheFunction('key', fetchFunction)) // Will print 'Fetching...' and 'result'
* ```
*/
export const createCacheFunction = (cacheExpirationTimeMs: number) => {
const cacheObject: Record<string, any> = {}
const cacheLastUpdated: Record<string, Date> = {}
return async <CacheValueType>(options: {
cacheKey: string
fetchFunctionIfCacheMiss: () => Promise<CacheValueType>
}): Promise<CacheValueType> => {
if (!options?.cacheKey) throw new Error('cacheKey is required')
if (!options?.fetchFunctionIfCacheMiss) throw new Error('fetchFunctionIfCacheMiss is required')
const cacheKey = options.cacheKey
const fetchFunctionIfCacheMiss = options.fetchFunctionIfCacheMiss
// Perform cache eviction of all keys that are older than the cacheExpirationTimeMs
// This prevents memory leaks
Object.keys(cacheLastUpdated).forEach(key => {
if (new Date().getTime() - cacheLastUpdated[key].getTime() > cacheExpirationTimeMs) {
delete cacheObject[key]
delete cacheLastUpdated[key]
}
})
// If the cache is empty or the cache is expired, fetch the data and update the cache
if (!cacheObject[cacheKey] || new Date().getTime() - cacheLastUpdated[cacheKey].getTime() > cacheExpirationTimeMs) {
cacheObject[cacheKey] = await fetchFunctionIfCacheMiss()
cacheLastUpdated[cacheKey] = new Date()
}
return cacheObject[cacheKey]
}
}
export type CacheMap<T> = { [cacheKey: string]: { data: T; lastUpdated: Date } }
/**
* Read from cache if available, otherwise fetch the data and update the cache. Also perform cache eviction.
*
* @param options.cacheMap - The cache map to use
* @param options.cacheExpirationTimeMs - The expiration time of the cache
* @param options.cacheKey - The key to use in the cache map
* @param options.fetchFunctionIfCacheMiss - The function to call if the cache is empty or expired
* @returns The data from the cache or the fetch function
*
* @example
* ```ts
* const myFriends: CacheMap<{ name: string; age: number }> = {}
*
* async function fetchMyFriendsFromCity(fromCity: string) {
* // This is to preserve the name of the function in the stack trace
* const fetchMyFriendsFromCity = async () => {
* console.log('Fetching my friends from the database...')
* return (await db.query('SELECT * FROM friends WHERE city = ?', [fromCity])).rows
* }
*
* return withCache({
* cacheMap: myFriends,
* cacheExpirationTimeMs: 30 * 1000, // 30 seconds cache
* cacheKey: fromCity,
* fetchFunctionIfCacheMiss: fetchMyFriendsFromCity,
* })
*
* console.log(await fetchMyFriendsFromCity('New York')) // Fetches from the database
* console.log(await fetchMyFriendsFromCity('New York')) // Reads from the cache
*
* await sleep(45 * 1000) // Wait for the cache to expire
*
* console.log(await fetchMyFriendsFromCity('New York')) // Fetches from the database
* ```
*/
export const withCache = async <T>(options: {
cacheMap: CacheMap<any>
cacheExpirationTimeMs: number
cacheKey: string
fetchFunctionIfCacheMiss: () => Promise<T>
}): Promise<T> => {
const { cacheMap, cacheExpirationTimeMs, cacheKey, fetchFunctionIfCacheMiss } = options
// Perform cache eviction of all keys that are older than the cacheExpirationTimeMs
// This prevents memory leaks
Object.keys(cacheMap).forEach(cacheKey => {
if (new Date().getTime() - cacheMap[cacheKey].lastUpdated.getTime() > cacheExpirationTimeMs) {
delete cacheMap[cacheKey]
}
})
// If the cache is empty or the cache is expired, fetch the data and update the cache
if (!cacheMap[cacheKey] || new Date().getTime() - cacheMap[cacheKey].lastUpdated.getTime() > cacheExpirationTimeMs) {
cacheMap[cacheKey] = {
data: await fetchFunctionIfCacheMiss(),
lastUpdated: new Date(),
}
}
return cacheMap[cacheKey].data
}
export type CacheLockableMap<T> = { [cacheKey: string]: { data?: T; lastUpdated: Date; isLocked: boolean } }
/**
* Read from cache if available, otherwise fetch the data with lock then update the cache. Also perform cache eviction.
*
* If there is a parallel request for the same cache key, the function will wait for the result of the first request with a time limit.
*
* @param options.cacheMap The cache map to use
* @param options.cacheExpirationTimeMs The expiration time of the cache
* @param options.cacheMaxWaitForResultIfLockedMs The maximum number of time to wait for the result, will check for cache unlock every 50ms
* @param options.cacheKey The key to use in the cache map
* @param options.fetchFunctionIfCacheMiss The function to call if the cache is empty or expired
* @returns The data from the cache or the fetch function
*
* @example
* ```ts
* const myFriends: CacheLockableMap<{ name: string; age: number }> = {}
*
* async function fetchMyFriendsFromCity(fromCity: string) {
* // This is to preserve the name of the function in the stack trace
* const fetchMyFriendsFromCity = async () => {
* console.log('Fetching my friends from the database...')
* await new Promise(resolve => setTimeout(resolve, 3000)) // Simulate a slow database query
* return (await db.query('SELECT * FROM friends WHERE city = ?', [fromCity])).rows
* }
*
* return withCacheLockable({
* cacheMap: myFriends,
* cacheExpirationTimeMs: 30 * 1000, // 30 seconds cache
* cacheMaxWaitForResultIfLockedMs: 15 * 1000, // 15 seconds
* cacheKey: fromCity,
* fetchFunctionIfCacheMiss: fetchMyFriendsFromCity,
* })
*
* const taskA = fetchMyFriendsFromCity('New York') // Fetches from the database with lock
* const taskB = fetchMyFriendsFromCity('New York') // Waiting for taskA to finish then reads from the cache
*
* await taskA
* await taskB // Will be instant because it reads from the cache
*
* console.log(taskA)
* console.log(taskB) // Same result as taskA
*
*
* console.log(await fetchMyFriendsFromCity('New York')) // Data is already in cache, reads from the cache
*
* await sleep(45 * 1000) // Wait for the cache to expire
*
* console.log(await fetchMyFriendsFromCity('New York')) // Fetches from the database
* ```
*/
export const withCacheLockable = async <T>(
options: {
cacheMap: CacheLockableMap<T>
cacheExpirationTimeMs: number
cacheMaxWaitForResultIfLockedMs: number
cacheKey: string
fetchFunctionIfCacheMiss: () => Promise<T>
},
__internal__remainingTimeToWaitForLockMs?: number,
): Promise<T> => {
const { cacheMap, cacheExpirationTimeMs, cacheMaxWaitForResultIfLockedMs, cacheKey, fetchFunctionIfCacheMiss } = options
const CACHE_LOCK_CHECK_INTERVAL_MS = 50
// Initialize the remaining time to wait for the result
if (__internal__remainingTimeToWaitForLockMs === undefined) {
__internal__remainingTimeToWaitForLockMs = cacheMaxWaitForResultIfLockedMs
}
if (__internal__remainingTimeToWaitForLockMs <= 0) {
throw new Error(`Exceeded maximum time to wait for the result of the locked cache with cacheKey=${cacheKey}`)
}
// Perform cache eviction of all keys that are older than the cacheExpirationTimeMs and are not locked
// This prevents memory leaks
Object.keys(cacheMap).forEach(cacheKey => {
if (!cacheMap[cacheKey].isLocked && new Date().getTime() - cacheMap[cacheKey].lastUpdated.getTime() > cacheExpirationTimeMs) {
delete cacheMap[cacheKey]
}
})
if (cacheMap[cacheKey]?.isLocked) {
// If the cache is locked, wait for the result and retry
await new Promise(resolve => setTimeout(resolve, CACHE_LOCK_CHECK_INTERVAL_MS))
return withCacheLockable(options, __internal__remainingTimeToWaitForLockMs - CACHE_LOCK_CHECK_INTERVAL_MS)
}
// If the cache is empty or the cache is expired, fetch the data and update the cache
if (!cacheMap[cacheKey] || new Date().getTime() - cacheMap[cacheKey].lastUpdated.getTime() > cacheExpirationTimeMs) {
try {
// Lock the cache key then load the data then unlock the cache
cacheMap[cacheKey] = {
lastUpdated: new Date(),
isLocked: true,
}
const data = await fetchFunctionIfCacheMiss()
cacheMap[cacheKey] = {
data,
lastUpdated: new Date(),
isLocked: false,
}
} catch (error) {
// If an error occurs, unlock the cache and rethrow the error
delete cacheMap[cacheKey]
throw error
}
}
return cacheMap[cacheKey].data!
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment