type CacheMetadata = { createdTime: number maxAge: number | null expires: number | null } function shouldRefresh(metadata: CacheMetadata) { if (metadata.maxAge) { return Date.now() > metadata.createdTime + metadata.maxAge } if (metadata.expires) { return Date.now() > metadata.expires } return false } async function cachified(options: { key: string getFreshValue: () => Promise checkValue?: (value: ReturnValue) => boolean forceFresh?: boolean request?: Request fallbackToCache?: boolean timings?: Timings timingType?: string maxAge?: number expires?: Date }): Promise { const { key, getFreshValue, request, forceFresh = request ? await shouldForceFresh(request) : false, checkValue = value => Boolean(value), fallbackToCache = true, timings, timingType = 'getting fresh value', maxAge, expires, } = options if (!forceFresh) { try { const cached = await time({ name: `redis.get(${key})`, type: 'redis cache read', fn: () => get(key), timings, }) if (cached) { const cachedParsed = JSON.parse(cached) as { metadata?: CacheMetadata value?: ReturnValue } if (cachedParsed.metadata && shouldRefresh(cachedParsed.metadata)) { // time to refresh the value. Fire and forget so we don't slow down // this request // originally I thought it may be good to make sure we don't have // multiple requests for the same key triggering multiple refreshes // like this, but as I thought about it I realized the liklihood of // this causing real issues is pretty small (unless there's a failure) // to update the value, in which case we should probably be notified // anyway... void cachified({...options, forceFresh: true}) } if (cachedParsed.value && checkValue(cachedParsed.value)) { return cachedParsed.value } else { console.warn( `check failed for cached value of ${key}. Deleting the cache key and trying to get a fresh value.`, cachedParsed, ) await del(key) } } } catch (error: unknown) { console.error(`error with cache at ${key}`, getErrorMessage(error)) } } const value = await time({ name: `getFreshValue for ${key}`, type: timingType, fn: getFreshValue, timings, }).catch((error: unknown) => { // If we got this far without forceFresh then we know there's nothing // in the cache so no need to bother. So we need both the option to fallback // and the ability. if (fallbackToCache && forceFresh) { return cachified({...options, forceFresh: false}) } else { throw error } }) if (checkValue(value)) { const metadata: CacheMetadata = { maxAge: maxAge ?? null, expires: expires?.getTime() ?? null, createdTime: Date.now(), } void set(key, JSON.stringify({metadata, value})).catch(error => { console.error(`error setting redis.${key}`, getErrorMessage(error)) }) } else { console.error(`check failed for fresh value of ${key}:`, value) throw new Error(`check failed for fresh value of ${key}`) } return value } async function shouldForceFresh(request: Request) { return ( new URL(request.url).searchParams.has('fresh') && (await getUser(request))?.role === 'ADMIN' ) }