UNPKG

mastercache

Version:

Multi-tier cache module for Node.js. Redis, Upstash, CloudfareKV, File, in-memory and others drivers

239 lines (204 loc) 7.51 kB
import type { MutexInterface } from 'async-mutex'; import { Locks } from './locks'; import { events } from '../events/index'; import type { Factory } from '../types/helpers'; import { FactoryRunner } from './factory-runner'; import type { CacheEvent } from '../types/events'; import { E_FACTORY_SOFT_TIMEOUT } from '../errors'; import type { CacheStack } from './stack/cache-stack'; import type { CacheEntry } from './cache-entry/cache-entry'; import type { CacheStackWriter } from './stack/cache-stack-writer'; import type { CacheEntryOptions } from './cache-entry/cache-entry-options'; export class GetSetHandler { /** * A map that will hold active locks for each key */ #locks = new Locks(); #factoryRunner: FactoryRunner; constructor( protected stack: CacheStack, protected stackWriter: CacheStackWriter, ) { this.#factoryRunner = new FactoryRunner(this.stack, this.stackWriter, this.#locks); } get logger() { return this.stack.logger; } get emitter() { return this.stack.emitter; } /** * Emit a CacheEvent using the emitter */ #emit(event: CacheEvent) { return this.stack.emitter.emit(event.name, event.toJSON()); } /** * Refresh a cache item before it expires */ async #earlyExpirationRefresh(key: string, factory: Factory, options: CacheEntryOptions) { this.logger.debug({ key, name: this.stack.name, opId: options.id }, 'try to early refresh'); const lock = this.#locks.getOrCreateForKey(key); /** * If lock is already acquired, then just exit. We only want to run * the factory once, in background. */ if (lock.isLocked()) { return; } await lock .runExclusive(async () => { this.logger.trace( { key, cache: this.stack.name, opId: options.id }, 'acquired lock for refresh', ); await this.stackWriter.set(key, await factory(), options); }) .catch((error) => { const msg = 'factory error in early refresh'; this.logger.error({ key, cache: this.stack.name, opId: options.id, error }, msg); throw error; }); } /** * Returns a value from the local cache and emit a CacheHit event */ #returnLocalCacheValue( key: string, item: CacheEntry, options: CacheEntryOptions, logMsg?: string, ) { const isLogicallyExpired = item.isLogicallyExpired(); logMsg = logMsg ?? 'local cache hit'; this.#emit(new events.CacheHit(key, item.getValue(), this.stack.name, isLogicallyExpired)); this.logger.trace({ key, cache: this.stack.name, opId: options.id }, logMsg); return item.getValue(); } /** * Returns a value from the remote cache and emit a CacheHit event */ async #returnRemoteCacheValue(key: string, item: CacheEntry, options: CacheEntryOptions) { this.logger.trace({ key, cache: this.stack.name, opId: options.id }, 'remote cache hit'); this.stack.l1?.set(key, item.serialize(), options); this.#emit(new events.CacheHit(key, item.getValue(), this.stack.name)); return item.getValue(); } /** * Try acquiring a lock for a key * * If we have a fallback value, grace period enabled, and a soft timeout configured * we will wait at most the soft timeout to acquire the lock */ #acquireLock(key: string, hasFallback: boolean, options: CacheEntryOptions) { const lock = this.#locks.getOrCreateForKey(key, options.getApplicableLockTimeout(hasFallback)); return lock.acquire(); } #returnGracedValueOrThrow( key: string, item: CacheEntry | undefined, options: CacheEntryOptions, err: Error, ) { if (options.isGracePeriodEnabled && item) { return this.#returnLocalCacheValue(key, item, options, 'local cache hit (graced)'); } throw err; } async #applyFallbackAndReturnGracedValue( key: string, item: CacheEntry, options: CacheEntryOptions, ) { if (options.gracePeriod.enabled && options.gracePeriod.fallbackDuration) { this.logger.trace( { key, cache: this.stack.name, opId: options.id }, 'apply fallback duration', ); this.stack.l1?.set( key, item.applyFallbackDuration(options.gracePeriod.fallbackDuration).serialize(), options, ); } this.logger.trace({ key, cache: this.stack.name, opId: options.id }, 'returns stale value'); this.#emit(new events.CacheHit(key, item.getValue(), this.stack.name, true)); return item.getValue(); } /** * Check if a cache item is not undefined and not logically expired */ #isItemValid(item: CacheEntry | undefined): item is CacheEntry { return !!item && !item.isLogicallyExpired(); } async handle(key: string, factory: Factory, options: CacheEntryOptions) { let localItem: CacheEntry | undefined; /** * First we check the local cache. If we have a valid item, just * returns it without acquiring a lock. */ localItem = this.stack.l1?.get(key, options); if (this.#isItemValid(localItem)) { if (localItem?.isEarlyExpired()) this.#earlyExpirationRefresh(key, factory, options); return this.#returnLocalCacheValue(key, localItem, options); } /** * Since we didn't find a valid item in the local cache, we need to * check the remote cache, or invoke the factory. * * We acquire a lock to prevent a cache stampede. */ let releaser: MutexInterface.Releaser; try { releaser = await this.#acquireLock(key, !!localItem, options); } catch (err) { return this.#returnGracedValueOrThrow(key, localItem, options, err); } this.logger.trace({ key, cache: this.stack.name, opId: options.id }, 'acquired lock'); /** * We need to check the local cache again, because another process * could have written a value while we were waiting for the lock. */ localItem = this.stack.l1?.get(key, options); if (this.#isItemValid(localItem)) { this.#locks.release(key, releaser); return this.#returnLocalCacheValue(key, localItem, options, 'local cache hit after lock'); } /** * If local cache was empty, maybe there is something in the remote * cache. If we find a valid item, we save it in the local cache * and returns it. */ const remoteItem = await this.stack.l2?.get(key, options); if (this.#isItemValid(remoteItem)) { this.#locks.release(key, releaser); return this.#returnRemoteCacheValue(key, remoteItem, options); } try { const hasFallback = !!localItem || !!remoteItem; return await this.#factoryRunner.run(key, factory, hasFallback, options, releaser); } catch (err) { /** * If we hitted a soft timeout and we have a graced value, returns it */ const staleItem = remoteItem ?? localItem; if (err instanceof E_FACTORY_SOFT_TIMEOUT && staleItem) { return this.#returnGracedValueOrThrow(key, staleItem, options, err); } /** * Otherwise, that means we had a factory error. If we have a graced * value, returns it */ this.logger.trace( { key, cache: this.stack.name, opId: options.id, error: err }, 'factory error', ); if (staleItem && options.isGracePeriodEnabled) { this.#locks.release(key, releaser); return this.#applyFallbackAndReturnGracedValue(key, staleItem, options); } this.#locks.release(key, releaser); throw err; } } }