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
text/typescript
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;
}
}
}