UNPKG

mastercache

Version:

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

802 lines (788 loc) 23.5 kB
// ../../node_modules/.pnpm/async-mutex@0.5.0/node_modules/async-mutex/index.mjs var E_TIMEOUT = new Error("timeout while waiting for mutex to become available"); var E_ALREADY_LOCKED = new Error("mutex already locked"); var E_CANCELED = new Error("request for lock canceled"); var __awaiter$2 = function(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); } return new (P || (P = Promise))(function(resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var Semaphore = class { constructor(_value, _cancelError = E_CANCELED) { this._value = _value; this._cancelError = _cancelError; this._queue = []; this._weightedWaiters = []; } acquire(weight = 1, priority = 0) { if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); return new Promise((resolve, reject) => { const task = { resolve, reject, weight, priority }; const i = findIndexFromEnd(this._queue, (other) => priority <= other.priority); if (i === -1 && weight <= this._value) { this._dispatchItem(task); } else { this._queue.splice(i + 1, 0, task); } }); } runExclusive(callback_1) { return __awaiter$2(this, arguments, void 0, function* (callback, weight = 1, priority = 0) { const [value, release] = yield this.acquire(weight, priority); try { return yield callback(value); } finally { release(); } }); } waitForUnlock(weight = 1, priority = 0) { if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); if (this._couldLockImmediately(weight, priority)) { return Promise.resolve(); } else { return new Promise((resolve) => { if (!this._weightedWaiters[weight - 1]) this._weightedWaiters[weight - 1] = []; insertSorted(this._weightedWaiters[weight - 1], { resolve, priority }); }); } } isLocked() { return this._value <= 0; } getValue() { return this._value; } setValue(value) { this._value = value; this._dispatchQueue(); } release(weight = 1) { if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); this._value += weight; this._dispatchQueue(); } cancel() { this._queue.forEach((entry) => entry.reject(this._cancelError)); this._queue = []; } _dispatchQueue() { this._drainUnlockWaiters(); while (this._queue.length > 0 && this._queue[0].weight <= this._value) { this._dispatchItem(this._queue.shift()); this._drainUnlockWaiters(); } } _dispatchItem(item) { const previousValue = this._value; this._value -= item.weight; item.resolve([previousValue, this._newReleaser(item.weight)]); } _newReleaser(weight) { let called = false; return () => { if (called) return; called = true; this.release(weight); }; } _drainUnlockWaiters() { if (this._queue.length === 0) { for (let weight = this._value; weight > 0; weight--) { const waiters = this._weightedWaiters[weight - 1]; if (!waiters) continue; waiters.forEach((waiter) => waiter.resolve()); this._weightedWaiters[weight - 1] = []; } } else { const queuedPriority = this._queue[0].priority; for (let weight = this._value; weight > 0; weight--) { const waiters = this._weightedWaiters[weight - 1]; if (!waiters) continue; const i = waiters.findIndex((waiter) => waiter.priority <= queuedPriority); (i === -1 ? waiters : waiters.splice(0, i)).forEach((waiter) => waiter.resolve()); } } } _couldLockImmediately(weight, priority) { return (this._queue.length === 0 || this._queue[0].priority < priority) && weight <= this._value; } }; function insertSorted(a, v) { const i = findIndexFromEnd(a, (other) => v.priority <= other.priority); a.splice(i + 1, 0, v); } function findIndexFromEnd(a, predicate) { for (let i = a.length - 1; i >= 0; i--) { if (predicate(a[i])) { return i; } } return -1; } var __awaiter$1 = function(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); } return new (P || (P = Promise))(function(resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var Mutex = class { constructor(cancelError) { this._semaphore = new Semaphore(1, cancelError); } acquire() { return __awaiter$1(this, arguments, void 0, function* (priority = 0) { const [, releaser] = yield this._semaphore.acquire(1, priority); return releaser; }); } runExclusive(callback, priority = 0) { return this._semaphore.runExclusive(() => callback(), 1, priority); } isLocked() { return this._semaphore.isLocked(); } waitForUnlock(priority = 0) { return this._semaphore.waitForUnlock(1, priority); } release() { if (this._semaphore.isLocked()) this._semaphore.release(); } cancel() { return this._semaphore.cancel(); } }; var __awaiter = function(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); } return new (P || (P = Promise))(function(resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; function withTimeout(sync, timeout, timeoutError = E_TIMEOUT) { return { acquire: (weightOrPriority, priority) => { let weight; if (isSemaphore(sync)) { weight = weightOrPriority; } else { weight = void 0; priority = weightOrPriority; } if (weight !== void 0 && weight <= 0) { throw new Error(`invalid weight ${weight}: must be positive`); } return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { let isTimeout = false; const handle = setTimeout(() => { isTimeout = true; reject(timeoutError); }, timeout); try { const ticket = yield isSemaphore(sync) ? sync.acquire(weight, priority) : sync.acquire(priority); if (isTimeout) { const release = Array.isArray(ticket) ? ticket[1] : ticket; release(); } else { clearTimeout(handle); resolve(ticket); } } catch (e) { if (!isTimeout) { clearTimeout(handle); reject(e); } } })); }, runExclusive(callback, weight, priority) { return __awaiter(this, void 0, void 0, function* () { let release = () => void 0; try { const ticket = yield this.acquire(weight, priority); if (Array.isArray(ticket)) { release = ticket[1]; return yield callback(ticket[0]); } else { release = ticket; return yield callback(); } } finally { release(); } }); }, release(weight) { sync.release(weight); }, cancel() { return sync.cancel(); }, waitForUnlock: (weightOrPriority, priority) => { let weight; if (isSemaphore(sync)) { weight = weightOrPriority; } else { weight = void 0; priority = weightOrPriority; } if (weight !== void 0 && weight <= 0) { throw new Error(`invalid weight ${weight}: must be positive`); } return new Promise((resolve, reject) => { const handle = setTimeout(() => reject(timeoutError), timeout); (isSemaphore(sync) ? sync.waitForUnlock(weight, priority) : sync.waitForUnlock(priority)).then(() => { clearTimeout(handle); resolve(); }); }); }, isLocked: () => sync.isLocked(), getValue: () => sync.getValue(), setValue: (value) => sync.setValue(value) }; } function isSemaphore(sync) { return sync.getValue !== void 0; } // src/cache/locks.ts var Locks = class { /** * A map that will hold active locks for each key */ #locks = /* @__PURE__ */ new Map(); /** * For a given key, get or create a new lock * * @param key Key to get or create a lock for * @param timeout Time to wait to acquire the lock */ getOrCreateForKey(key, timeout) { let lock = this.#locks.get(key); if (!lock) { lock = new Mutex(); this.#locks.set(key, lock); } return timeout ? withTimeout(lock, timeout) : lock; } release(key, releaser) { releaser(); this.#locks.delete(key); } }; // src/events/cache/cache-hit.ts var CacheHit = class { constructor(key, value, store, graced = false) { this.key = key; this.value = value; this.store = store; this.graced = graced; } name = "cache:hit"; toJSON() { return { key: this.key, value: this.value, store: this.store, graced: this.graced }; } }; // src/events/cache/cache-miss.ts var CacheMiss = class { constructor(key, store) { this.key = key; this.store = store; } name = "cache:miss"; toJSON() { return { key: this.key, store: this.store }; } }; // src/events/cache/cache-cleared.ts var CacheCleared = class { constructor(store) { this.store = store; } name = "cache:cleared"; toJSON() { return { store: this.store }; } }; // src/events/cache/cache-deleted.ts var CacheDeleted = class { constructor(key, store) { this.key = key; this.store = store; } name = "cache:deleted"; toJSON() { return { key: this.key, store: this.store }; } }; // src/events/cache/cache-written.ts var CacheWritten = class { constructor(key, value, store) { this.key = key; this.value = value; this.store = store; } name = "cache:written"; toJSON() { return { key: this.key, store: this.store, value: this.value }; } }; // src/events/bus/bus-message-received.ts var BusMessageReceived = class { constructor(message) { this.message = message; } name = "bus:message:received"; toJSON() { return { keys: this.message.keys, type: this.message.type }; } }; // src/events/bus/bus-message-published.ts var BusMessagePublished = class { constructor(message) { this.message = message; } name = "bus:message:published"; toJSON() { return { keys: this.message.keys, type: this.message.type }; } }; // src/events/index.ts var events = { BusMessagePublished, BusMessageReceived, CacheHit, CacheMiss, CacheCleared, CacheDeleted, CacheWritten }; // ../../node_modules/.pnpm/p-timeout@6.1.3/node_modules/p-timeout/index.js var TimeoutError = class extends Error { constructor(message) { super(message); this.name = "TimeoutError"; } }; var AbortError = class extends Error { constructor(message) { super(); this.name = "AbortError"; this.message = message; } }; var getDOMException = (errorMessage) => globalThis.DOMException === void 0 ? new AbortError(errorMessage) : new DOMException(errorMessage); var getAbortedReason = (signal) => { const reason = signal.reason === void 0 ? getDOMException("This operation was aborted.") : signal.reason; return reason instanceof Error ? reason : getDOMException(reason); }; function pTimeout(promise, options) { const { milliseconds, fallback, message, customTimers = { setTimeout, clearTimeout } } = options; let timer; const wrappedPromise = new Promise((resolve, reject) => { if (typeof milliseconds !== "number" || Math.sign(milliseconds) !== 1) { throw new TypeError(`Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\``); } if (options.signal) { const { signal } = options; if (signal.aborted) { reject(getAbortedReason(signal)); } const abortHandler = () => { reject(getAbortedReason(signal)); }; signal.addEventListener("abort", abortHandler, { once: true }); promise.finally(() => { signal.removeEventListener("abort", abortHandler); }); } if (milliseconds === Number.POSITIVE_INFINITY) { promise.then(resolve, reject); return; } const timeoutError = new TimeoutError(); timer = customTimers.setTimeout.call(void 0, () => { if (fallback) { try { resolve(fallback()); } catch (error) { reject(error); } return; } if (typeof promise.cancel === "function") { promise.cancel(); } if (message === false) { resolve(); } else if (message instanceof Error) { reject(message); } else { timeoutError.message = message ?? `Promise timed out after ${milliseconds} milliseconds`; reject(timeoutError); } }, milliseconds); (async () => { try { resolve(await promise); } catch (error) { reject(error); } })(); }); const cancelablePromise = wrappedPromise.finally(() => { cancelablePromise.clear(); }); cancelablePromise.clear = () => { customTimers.clearTimeout.call(void 0, timer); timer = void 0; }; return cancelablePromise; } // src/libs/exception.ts import { format } from "node:util"; var Exception = class extends Error { /** * Name of the class that raised the exception. */ name; /** * A status code for the error. Usually helpful when converting errors * to HTTP responses. */ status; constructor(message, options) { super(message, options); const ErrorConstructor = this.constructor; this.name = ErrorConstructor.name; this.message = message || ErrorConstructor.message || ""; this.status = options?.status || ErrorConstructor.status || 500; const code = options?.code || ErrorConstructor.code; if (code !== void 0) { this.code = code; } const help = ErrorConstructor.help; if (help !== void 0) { this.help = help; } Error.captureStackTrace(this, ErrorConstructor); } get [Symbol.toStringTag]() { return this.constructor.name; } toString() { if (this.code) { return `${this.name} [${this.code}]: ${this.message}`; } return `${this.name}: ${this.message}`; } }; function createError(message, code, status) { return class extends Exception { static message = message; static code = code; static status = status; constructor(args, options) { super(format(message, ...args || []), options); this.name = "Exception"; } }; } // src/errors.ts var E_FACTORY_SOFT_TIMEOUT = createError( "Factory has timed out after waiting for soft timeout", "E_FACTORY_SOFT_TIMEOUT" ); var E_FACTORY_HARD_TIMEOUT = createError( "Factory has timed out after waiting for hard timeout", "E_FACTORY_HARD_TIMEOUT" ); // src/cache/factory-runner.ts var FactoryRunner = class { #stack; #stackWriter; #locks; constructor(stack, stackWriter, locks) { this.#stack = stack; this.#stackWriter = stackWriter; this.#locks = locks; } async saveBackgroundFactoryResult(key, factoryResult, options, lockReleaser) { await this.#stackWriter.set(key, factoryResult, options); this.#locks.release(key, lockReleaser); } async writeFactoryResult(key, item, options, lockReleaser) { await this.#stackWriter.set(key, item, options); this.#stack.emit(new events.CacheMiss(key, this.#stack.name)); this.#stack.logger.trace({ key, cache: this.#stack.name, opId: options.id }, "cache miss"); this.#locks.release(key, lockReleaser); } async run(key, factory, hasFallback, options, lockReleaser) { const timeoutDuration = options.factoryTimeout(hasFallback); const timeoutException = timeoutDuration === options.timeouts?.hard ? E_FACTORY_HARD_TIMEOUT : E_FACTORY_SOFT_TIMEOUT; const promisifiedFactory = async () => { return await factory({ setTtl: (ttl) => options.setLogicalTtl(ttl) }); }; const factoryPromise = promisifiedFactory(); const factoryResult = await pTimeout(factoryPromise, { milliseconds: timeoutDuration ?? Number.POSITIVE_INFINITY, fallback: async () => { factoryPromise.then((result) => this.saveBackgroundFactoryResult(key, result, options, lockReleaser)).catch(() => { }).finally(() => this.#locks.release(key, lockReleaser)); throw new timeoutException(); } }); await this.writeFactoryResult(key, factoryResult, options, lockReleaser); return factoryResult; } }; // src/cache/get-set-handler.ts var GetSetHandler = class { constructor(stack, stackWriter) { this.stack = stack; this.stackWriter = stackWriter; this.#factoryRunner = new FactoryRunner(this.stack, this.stackWriter, this.#locks); } /** * A map that will hold active locks for each key */ #locks = new Locks(); #factoryRunner; get logger() { return this.stack.logger; } get emitter() { return this.stack.emitter; } /** * Emit a CacheEvent using the emitter */ #emit(event) { return this.stack.emitter.emit(event.name, event.toJSON()); } /** * Refresh a cache item before it expires */ async #earlyExpirationRefresh(key, factory, options) { this.logger.debug({ key, name: this.stack.name, opId: options.id }, "try to early refresh"); const lock = this.#locks.getOrCreateForKey(key); 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, item, options, logMsg) { 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, item, options) { 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, hasFallback, options) { const lock = this.#locks.getOrCreateForKey(key, options.getApplicableLockTimeout(hasFallback)); return lock.acquire(); } #returnGracedValueOrThrow(key, item, options, err) { if (options.isGracePeriodEnabled && item) { return this.#returnLocalCacheValue(key, item, options, "local cache hit (graced)"); } throw err; } async #applyFallbackAndReturnGracedValue(key, item, options) { 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) { return !!item && !item.isLogicallyExpired(); } async handle(key, factory, options) { let localItem; 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); } let 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"); 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"); } 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) { const staleItem = remoteItem ?? localItem; if (err instanceof E_FACTORY_SOFT_TIMEOUT && staleItem) { return this.#returnGracedValueOrThrow(key, staleItem, options, err); } 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; } } }; export { GetSetHandler }; //# sourceMappingURL=get-set-handler.js.map