@acuris/leprechaun-cache
Version:
Caching library that supports double checked caching and stale returns to avoid stampede and slow responses
193 lines (167 loc) • 4.89 kB
text/typescript
import { CacheStore, Cacheable, OnCacheMiss, LeprechaunCacheOptions, CacheItem } from './types'
interface LockResult {
lockId: string
didSpin: boolean
}
function delay(durationMs: number): Promise<void> {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, durationMs)
})
}
const defaultBackgroundErrorHandler = (_: Error) => {}
export class LeprechaunCache<T extends Cacheable = Cacheable> {
private softTtlMs: number
private hardTtlMs: number
private lockTtlMs: number
private waitTimeMs: number
private returnStale: boolean
private spinWaitCount: number
private cacheStore: CacheStore<T>
private spinMs: number
private inProgress = new Map<string, Promise<T>>()
private onMiss: OnCacheMiss<T>
private keyPrefix: string
private onBackgroundError: (e: Error) => void
public constructor({
keyPrefix = '',
softTtlMs,
hardTtlMs,
lockTtlMs,
waitTimeMs = 0,
waitForUnlockMs,
cacheStore,
spinMs,
returnStale,
onMiss,
onBackgroundError = defaultBackgroundErrorHandler
}: LeprechaunCacheOptions<T>) {
this.hardTtlMs = hardTtlMs
this.softTtlMs = softTtlMs
this.lockTtlMs = lockTtlMs
this.waitTimeMs = waitTimeMs
this.spinWaitCount = Math.ceil(waitForUnlockMs / spinMs)
this.spinMs = spinMs
this.cacheStore = cacheStore
this.returnStale = returnStale
this.onMiss = onMiss
this.keyPrefix = keyPrefix
this.onBackgroundError = onBackgroundError
}
public async clear(key: string): Promise<boolean> {
const result = await this.cacheStore.del(this.keyPrefix + key)
this.inProgress.delete(key)
return result
}
public async get(key: string): Promise<T> {
let promise = this.inProgress.get(key)
if (promise === undefined) {
try {
promise = this.doGet(key, this.softTtlMs)
this.inProgress.set(key, promise)
return await promise
} finally {
this.inProgress.delete(key)
}
}
return promise
}
public async refresh(key: string): Promise<T> {
return this.updateCache(key, this.softTtlMs)
}
private async doGet(key: string, ttl: number): Promise<T> {
const result = await this.cacheStore.get(this.keyPrefix + key)
if (!result) {
return this.updateCache(key, ttl)
}
if (result.expiresAt > Date.now()) {
return result.data
}
const update = this.updateCache(key, ttl).catch(e => {
if (this.returnStale) {
this.onBackgroundError(e)
return result.data
}
throw e
})
if (!this.returnStale) {
return update
}
return this.race(update, result.data)
}
private async race(update: Promise<T>, staleData: T): Promise<T> {
update.catch(e => {
this.onBackgroundError(e)
return staleData
})
if (this.waitTimeMs <= 0) {
return staleData
}
const returnStaleAfterWaitTime: Promise<T> = new Promise(resolve => {
setTimeout(resolve, this.waitTimeMs, staleData)
})
return Promise.race([update, returnStaleAfterWaitTime])
}
private async spinLock(key: string): Promise<LockResult> {
const lock: LockResult = {
lockId: '',
didSpin: false
}
let i = 0
do {
lock.lockId = (await this.cacheStore.lock(this.keyPrefix + key, this.lockTtlMs)) || ''
if (lock.lockId) {
break
}
await delay(this.spinMs)
lock.didSpin = true
} while (i++ <= this.spinWaitCount)
return lock
}
private async updateCache(key: string, ttl: number): Promise<T> {
const lock = await this.spinLock(key)
if (!lock.lockId) {
throw new Error('unable to acquire lock and no data in cache')
}
if (lock.didSpin) {
//If we spun while getting the lock, then get the updated version (hopefully updated by another process)
const result = await this.cacheStore.get(this.keyPrefix + key)
if (result && result.data) {
await this.cacheStore.unlock(this.keyPrefix + key, lock.lockId)
return result.data
}
}
try {
const data = await this.onMiss(key)
//Set and unlock asynchronously so we don't delay the response
this.setAndUnlock(
key,
{
data,
expiresAt: Date.now() + ttl
},
lock
)
return data
} catch (e) {
this.unlock(key, lock)
throw e
}
}
private async unlock(key: string, lock: LockResult) {
try {
await this.cacheStore.unlock(this.keyPrefix + key, lock.lockId)
} catch (e) {
this.onBackgroundError(e)
}
}
private async setAndUnlock(key: string, cacheData: CacheItem<T>, lock: LockResult) {
try {
await this.cacheStore.set(this.keyPrefix + key, cacheData, this.hardTtlMs)
} catch (e) {
this.onBackgroundError(e)
}
await this.unlock(key, lock)
}
}