UNPKG

bentocache

Version:

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

1,608 lines (1,584 loc) 56.6 kB
import { Locks } from "./chunk-TLJ7G3ZX.js"; import "./chunk-D4QNXYVX.js"; import { CacheBusMessageType } from "./chunk-HVHH5I26.js"; import { hexoid, resolveTtl } from "./chunk-YAGCWAYQ.js"; import { BaseDriver } from "./chunk-BO75WXSS.js"; // src/errors.ts import { Exception } from "@poppinss/exception"; var FactorySoftTimeout = class _FactorySoftTimeout extends Exception { static code = "E_FACTORY_SOFT_TIMEOUT"; static message = "Factory has timed out after waiting for soft timeout"; key; constructor(key) { super(_FactorySoftTimeout.message, { code: _FactorySoftTimeout.code }); this.key = key; } }; var FactoryHardTimeout = class _FactoryHardTimeout extends Exception { static code = "E_FACTORY_HARD_TIMEOUT"; static message = "Factory has timed out after waiting for hard timeout"; key; constructor(key) { super(_FactoryHardTimeout.message, { code: _FactoryHardTimeout.code }); this.key = key; } }; var FactoryError = class _FactoryError extends Exception { static code = "E_FACTORY_ERROR"; static message = "Factory has thrown an error"; /** * The key for which the factory was called */ key; /** * If the error was thrown by a factory * running in the background */ isBackgroundFactory; constructor(key, cause, isBackground = false) { super(_FactoryError.message, { cause }); this.key = key; this.isBackgroundFactory = isBackground; } }; var UndefinedValueError = class extends Exception { static code = "E_UNDEFINED_VALUE"; constructor(key) { super(`Cannot set undefined value in the cache, key: ${key}`); } }; var L2CacheError = class _L2CacheError extends Exception { static code = "E_L2_CACHE_ERROR"; static message = "An error occurred while interacting with the L2 cache"; constructor(cause) { super(_L2CacheError.message, { cause }); } }; var errors = { E_FACTORY_ERROR: FactoryError, E_FACTORY_SOFT_TIMEOUT: FactorySoftTimeout, E_FACTORY_HARD_TIMEOUT: FactoryHardTimeout, E_UNDEFINED_VALUE: UndefinedValueError, E_L2_CACHE_ERROR: L2CacheError }; // src/cache/cache.ts import { is } from "@julr/utils/is"; // src/events/cache_events.ts var cacheEvents = { cleared(store) { return { name: "cache:cleared", data: { store } }; }, deleted(key, store) { return { name: "cache:deleted", data: { key, store } }; }, hit(key, value, store, layer = "l1", graced = false) { return { name: "cache:hit", data: { key, value, store, layer, graced } }; }, miss(key, store) { return { name: "cache:miss", data: { key, store } }; }, written(key, value, store) { return { name: "cache:written", data: { key, value, store } }; }, expire(key, store) { return { name: "cache:expire", data: { key, store } }; } }; // src/cache/factory_runner.ts import pTimeout from "p-timeout"; import { tryAsync } from "@julr/utils/functions"; var FactoryRunner = class { #locks; #stack; #skipSymbol = /* @__PURE__ */ Symbol("bentocache.skip"); constructor(stack, locks) { this.#stack = stack; this.#locks = locks; } /** * Process a factory error */ #processFactoryError(params, error) { this.#stack.logger.warn( { cache: this.#stack.name, opId: params.options.id, key: params.key, err: error }, "factory failed" ); this.#locks.release(params.key, params.lockReleaser); const factoryError = new errors.E_FACTORY_ERROR(params.key, error, params.isBackground); params.options.onFactoryError?.(factoryError); if (!params.isBackground) throw factoryError; return; } async #runFactory(params) { params.isBackground ??= false; const [result, error] = await tryAsync(async () => { const result2 = await params.factory({ skip: () => this.#skipSymbol, fail: (message) => { throw new Error(message ?? "Factory failed"); }, setTtl: (ttl) => params.options.setLogicalTtl(ttl), setTags: (tags) => params.options.tags.push(...tags), setOptions: (options) => { if (options.ttl) params.options.setLogicalTtl(options.ttl); params.options.skipBusNotify = options.skipBusNotify ?? false; params.options.skipL2Write = options.skipL2Write ?? false; }, gracedEntry: params.gracedValue ? { value: params.gracedValue?.entry.getValue() } : void 0 }); this.#stack.logger.info( { cache: this.#stack.name, opId: params.options.id, key: params.key }, "factory success" ); return result2; }); if (this.#skipSymbol === result) { this.#locks.release(params.key, params.lockReleaser); return; } if (error) return this.#processFactoryError(params, error); try { await this.#stack.set(params.key, result, params.options); } finally { this.#locks.release(params.key, params.lockReleaser); } return result; } async run(key, factory, gracedValue, options, lockReleaser) { const hasGracedValue = !!gracedValue; const timeout = options.factoryTimeout(hasGracedValue); if (timeout) { this.#stack.logger.info( { cache: this.#stack.name, opId: options.id, key }, `running factory with ${timeout.type} timeout of ${timeout.duration}ms` ); } else { this.#stack.logger.info({ cache: this.#stack.name, opId: options.id, key }, "running factory"); } if (options.shouldSwr(hasGracedValue)) { this.#runFactory({ key, factory, options, lockReleaser, isBackground: true }); throw new errors.E_FACTORY_SOFT_TIMEOUT(key); } const runFactory = this.#runFactory({ key, factory, options, lockReleaser, gracedValue }); const result = await pTimeout(runFactory, { milliseconds: timeout?.duration ?? Number.POSITIVE_INFINITY, fallback: async () => { this.#stack.logger.warn( { cache: this.#stack.name, opId: options.id, key }, `factory timed out after ${timeout?.duration}ms` ); throw new timeout.exception(key); } }); return result; } }; // src/cache/get_set/two_tier_handler.ts var TwoTierHandler = class { constructor(stack) { this.stack = stack; this.#factoryRunner = new FactoryRunner(this.stack, this.#locks); } /** * A map that will hold active locks for each key */ #locks = new Locks(); #factoryRunner; get logger() { return this.stack.logger; } /** * Emit a CacheEvent using the emitter */ #emit(event) { return this.stack.emitter.emit(event.name, event.data); } /** * Returns a value from the local cache and emit a CacheHit event */ #returnL1Value(key, item) { this.#emit(cacheEvents.hit(key, item.entry.getValue(), this.stack.name, "l1", item.isGraced)); return item.entry.getValue(); } /** * Returns a value from the remote cache and emit a CacheHit event */ async #returnRemoteCacheValue(key, item, options) { this.stack.l1?.set(key, item.entry.serialize(), options); this.#emit(cacheEvents.hit(key, item.entry.getValue(), this.stack.name, "l2")); return item.entry.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.isGraceEnabled() && item) return this.#returnL1Value(key, item); throw err; } async #applyFallbackAndReturnGracedValue(key, item, layer, options) { if (options.grace && options.graceBackoff) { this.logger.trace( { key, cache: this.stack.name, opId: options.id }, "apply fallback duration" ); this.stack.l1?.set(key, item.entry.applyBackoff(options.graceBackoff).serialize(), options); } this.logger.trace({ key, cache: this.stack.name, opId: options.id }, "returns stale value"); this.#emit(cacheEvents.hit(key, item.entry.getValue(), this.stack.name, layer, true)); return item.entry.getValue(); } async #lockAndHandle(key, factory, options, localItem) { let releaser; try { this.logger.trace({ key, cache: this.stack.name, opId: options.id }, "acquiring lock..."); releaser = await this.#acquireLock(key, !!localItem, options); } catch (err) { this.logger.trace({ key, cache: this.stack.name, opId: options.id }, "lock failed"); return this.#returnGracedValueOrThrow(key, localItem, options, err); } this.logger.trace({ key, cache: this.stack.name, opId: options.id }, "acquired lock"); let remoteItem; if (!options.forceFresh) { localItem = this.stack.l1?.get(key, options); const isLocalItemValid = await this.stack.isEntryValid(localItem); if (isLocalItemValid) { this.#locks.release(key, releaser); return this.#returnL1Value(key, localItem); } remoteItem = await this.stack.l2?.get(key, options); const isRemoteItemValid = await this.stack.isEntryValid(remoteItem); if (isRemoteItemValid) { this.#locks.release(key, releaser); return this.#returnRemoteCacheValue(key, remoteItem, options); } } try { const gracedValue = localItem || remoteItem; const result = await this.#factoryRunner.run(key, factory, gracedValue, options, releaser); this.#emit(cacheEvents.miss(key, this.stack.name)); return result; } catch (err) { const staleItem = remoteItem ?? localItem; if (err instanceof errors.E_FACTORY_SOFT_TIMEOUT && staleItem) { return this.#returnGracedValueOrThrow(key, staleItem, options, err); } this.logger.trace({ key, cache: this.stack.name, opId: options.id, err }, "factory error"); if (staleItem && options.isGraceEnabled()) { this.#locks.release(key, releaser); return this.#applyFallbackAndReturnGracedValue( key, staleItem, staleItem === localItem ? "l1" : "l2", options ); } this.#locks.release(key, releaser); throw err; } } handle(key, factory, options) { if (options.forceFresh) return this.#lockAndHandle(key, factory, options); const localItem = this.stack.l1?.get(key, options); const isLocalItemValid = this.stack.isEntryValid(localItem); if (isLocalItemValid instanceof Promise) { return isLocalItemValid.then((valid) => { if (valid) return this.#returnL1Value(key, localItem); return this.#lockAndHandle(key, factory, options, localItem); }); } if (isLocalItemValid) return this.#returnL1Value(key, localItem); return this.#lockAndHandle(key, factory, options, localItem); } }; // src/cache/get_set/single_tier_handler.ts var SingleTierHandler = class { constructor(stack) { this.stack = stack; this.#factoryRunner = new FactoryRunner(this.stack, this.#locks); } /** * A map that will hold active locks for each key */ #locks = new Locks(); #factoryRunner; get logger() { return this.stack.logger; } /** * Emit a CacheEvent using the emitter */ #emit(event) { return this.stack.emitter.emit(event.name, event.data); } /** * Returns a value from the remote cache and emit a CacheHit event */ async #returnRemoteCacheValue(key, item, options) { this.logger.logL2Hit({ cacheName: this.stack.name, key, options }); this.#emit(cacheEvents.hit(key, item.entry.getValue(), this.stack.name, "l2")); return item.entry.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.isGraceEnabled() && item) { this.#emit(cacheEvents.hit(key, item.entry.getValue(), this.stack.name, "l2", item.isGraced)); return item.entry.getValue(); } throw err; } async #applyFallbackAndReturnGracedValue(key, item, options) { if (options.grace && options.graceBackoff) { this.logger.trace( { key, cache: this.stack.name, opId: options.id }, "apply fallback duration" ); this.stack.l2?.set( key, item.entry.applyBackoff(options.graceBackoff).serialize(), options ); } this.logger.trace({ key, cache: this.stack.name, opId: options.id }, "returns stale value"); this.#emit(cacheEvents.hit(key, item.entry.getValue(), this.stack.name, "l2", true)); return item.entry.getValue(); } async handle(key, factory, options) { let remoteItem; let isRemoteItemValid = false; if (!options.forceFresh) { remoteItem = await this.stack.l2?.get(key, options); isRemoteItemValid = await this.stack.isEntryValid(remoteItem); if (isRemoteItemValid) { return this.#returnRemoteCacheValue(key, remoteItem, options); } } let releaser; try { releaser = await this.#acquireLock(key, !!remoteItem, options); } catch (err) { return this.#returnGracedValueOrThrow(key, remoteItem, options, err); } if (!options.forceFresh) { remoteItem = await this.stack.l2?.get(key, options); isRemoteItemValid = await this.stack.isEntryValid(remoteItem); if (isRemoteItemValid) { this.#locks.release(key, releaser); return this.#returnRemoteCacheValue(key, remoteItem, options); } } try { const result = await this.#factoryRunner.run(key, factory, remoteItem, options, releaser); this.#emit(cacheEvents.miss(key, this.stack.name)); return result; } catch (err) { const staleItem = remoteItem; if (err instanceof errors.E_FACTORY_SOFT_TIMEOUT && staleItem) { return this.#returnGracedValueOrThrow(key, staleItem, options, err); } this.logger.trace({ key, cache: this.stack.name, opId: options.id, err }, "factory error"); if (staleItem && options.isGraceEnabled()) { this.#locks.release(key, releaser); return this.#applyFallbackAndReturnGracedValue(key, staleItem, options); } this.#locks.release(key, releaser); throw err; } } }; // src/cache/get_set/get_set_handler.ts var GetSetHandler = class { constructor(stack) { this.stack = stack; this.#twoTierHandler = new TwoTierHandler(this.stack); this.#singleTierHandler = new SingleTierHandler(this.stack); } #singleTierHandler; #twoTierHandler; /** * In the case where we have an L1 and an L2, the flow is quite different * from the one where we only have an L2. * * Therefore we come here to determine which handler to use * depending on the configuration of the stack. */ handle(key, factory, options) { if (this.stack.l2 && !this.stack.l1) { return this.#singleTierHandler.handle(key, factory, options); } return this.#twoTierHandler.handle(key, factory, options); } }; // src/cache/cache.ts var Cache = class _Cache { /** * The name of the cache */ name; #getSetHandler; #stack; #options; constructor(name, stack) { this.name = name; this.#stack = stack; this.#options = stack.options; this.#getSetHandler = new GetSetHandler(this.#stack); this.#stack.setTagSystemGetSetHandler(this.#getSetHandler); } #resolveDefaultValue(defaultValue) { return is.function(defaultValue) ? defaultValue() : defaultValue ?? void 0; } /** * Returns a new instance of the driver namespaced */ namespace(namespace) { return new _Cache(this.name, this.#stack.namespace(namespace)); } async get(rawOptions) { const key = rawOptions.key; const defaultValueFn = this.#resolveDefaultValue(rawOptions.defaultValue); const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "get", key, options, cacheName: this.name }); const localItem = this.#stack.l1?.get(key, options); const isLocalItemValid = await this.#stack.isEntryValid(localItem); if (isLocalItemValid) { this.#stack.emit(cacheEvents.hit(key, localItem.entry.getValue(), this.name)); this.#options.logger.logL1Hit({ cacheName: this.name, key, options }); return localItem.entry.getValue(); } const remoteItem = await this.#stack.l2?.get(key, options); const isRemoteItemValid = await this.#stack.isEntryValid(remoteItem); if (isRemoteItemValid) { this.#stack.l1?.set(key, remoteItem.entry.serialize(), options); this.#stack.emit(cacheEvents.hit(key, remoteItem.entry.getValue(), this.name)); this.#options.logger.logL2Hit({ cacheName: this.name, key, options }); return remoteItem.entry.getValue(); } if (remoteItem && options.isGraceEnabled()) { this.#stack.l1?.set(key, remoteItem.entry.serialize(), options); this.#stack.emit(cacheEvents.hit(key, remoteItem.entry.serialize(), this.name, "l2", true)); this.#options.logger.logL2Hit({ cacheName: this.name, key, options, graced: true }); return remoteItem.entry.getValue(); } if (localItem && options.isGraceEnabled()) { this.#stack.emit(cacheEvents.hit(key, localItem.entry.serialize(), this.name, "l2", true)); this.#options.logger.logL1Hit({ cacheName: this.name, key, options, graced: true }); return localItem.entry.getValue(); } this.#stack.emit(cacheEvents.miss(key, this.name)); this.#options.logger.debug({ key, cacheName: this.name }, "cache miss. using default value"); return this.#resolveDefaultValue(defaultValueFn); } /** * Set a value in the cache * Returns true if the value was set, false otherwise */ set(rawOptions) { const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "set", options, key: rawOptions.key, cacheName: this.name }); return this.#stack.set(rawOptions.key, rawOptions.value, options); } /** * Set a value in the cache forever * Returns true if the value was set, false otherwise */ setForever(options) { return this.set({ ttl: null, ...options }); } /** * Retrieve an item from the cache if it exists, otherwise store the value * provided by the factory and return it */ getOrSet(rawOptions) { const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "getOrSet", key: rawOptions.key, cacheName: this.name, options }); return this.#getSetHandler.handle(rawOptions.key, rawOptions.factory, options); } /** * Retrieve an item from the cache if it exists, otherwise store the value * provided by the factory forever and return it */ getOrSetForever(rawOptions) { const options = this.#stack.defaultOptions.cloneWith({ ttl: null, ...rawOptions }); return this.#getSetHandler.handle(rawOptions.key, rawOptions.factory, options); } /** * Check if a key exists in the cache */ async has(options) { const key = options.key; const entryOptions = this.#stack.defaultOptions.cloneWith(options); this.#options.logger.logMethod({ method: "has", key, cacheName: this.name, options: entryOptions }); const localEntry = this.#stack.l1?.get(key, entryOptions); const isLocalEntryValid = await this.#stack.isEntryValid(localEntry); if (isLocalEntryValid) return true; const inRemote = await this.#stack.l2?.get(key, entryOptions); const isRemoteEntryValid = await this.#stack.isEntryValid(inRemote); if (isRemoteEntryValid) return true; return false; } /** * Check if key is missing in the cache */ async missing(options) { return !await this.has(options); } /** * Get the value of a key and delete it * Returns the value if the key exists, undefined otherwise */ async pull(key) { const value = await this.get({ key }); await this.delete({ key }); return value; } /** * Delete a key from the cache, emit cache:deleted event and * publish invalidation through the bus */ async delete(rawOptions) { const key = rawOptions.key; const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "delete", key, cacheName: this.name, options }); this.#stack.l1?.delete(key, options); await this.#stack.l2?.delete(key, options); this.#stack.emit(cacheEvents.deleted(key, this.name)); await this.#stack.publish({ type: CacheBusMessageType.Delete, keys: [key] }); return true; } /** * Invalidate all keys with the given tags */ async deleteByTag(rawOptions) { const tags = rawOptions.tags; const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "deleteByTag", cacheName: this.name, tags, options }); return await this.#stack.createTagInvalidations(tags); } /** * Delete multiple keys from local and remote cache * Then emit cache:deleted events for each key * And finally publish invalidation through the bus */ async deleteMany(rawOptions) { const keys = rawOptions.keys; const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "deleteMany", key: keys, cacheName: this.name, options }); this.#stack.l1?.deleteMany(keys, options); await this.#stack.l2?.deleteMany(keys, options); keys.forEach((key) => this.#stack.emit(cacheEvents.deleted(key, this.name))); await this.#stack.publish({ type: CacheBusMessageType.Delete, keys }); return true; } /** * Expire a key from the cache. * Entry will not be fully deleted but expired and * retained for the grace period if enabled. */ expire(rawOptions) { const key = rawOptions.key; const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "expire", cacheName: this.name, key, options }); return this.#stack.expire(key, options); } /** * Remove all items from the cache */ async clear(rawOptions) { const options = this.#stack.defaultOptions.cloneWith(rawOptions); this.#options.logger.logMethod({ method: "clear", cacheName: this.name, options }); await Promise.all([ this.#stack.l1?.clear(), this.#stack.l2?.clear(options), this.#stack.publish({ type: CacheBusMessageType.Clear, keys: [] }) ]); this.#stack.emit(cacheEvents.cleared(this.name)); } /** * Manually prune expired cache entries * * For drivers with native TTL support, this is typically a noop * For drivers without native TTL (PostgreSQL, File), this will remove expired entries */ prune() { return this.#stack.l2?.prune() ?? Promise.resolve(); } /** * Closes the connection to the cache */ async disconnect() { await Promise.all([ this.#stack.l1?.disconnect(), this.#stack.l2?.disconnect(), this.#stack.bus?.disconnect() ]); } }; // src/cache/cache_stack.ts import { is as is4 } from "@julr/utils/is"; // src/bus/bus.ts import { Bus as BoringBus } from "@boringnode/bus"; // src/events/bus_events.ts var busEvents = { messagePublished(message) { return { name: "bus:message:published", data: { message: { keys: message.keys, type: message.type } } }; }, messageReceived(message) { return { name: "bus:message:received", data: { message: { keys: message.keys, type: message.type } } }; } }; // src/bus/bus.ts var Bus = class { #bus; #logger; #emitter; #localCaches = /* @__PURE__ */ new Map(); #channelName = "bentocache.notifications"; constructor(name, driver, logger, emitter, options = {}) { this.#emitter = emitter; this.#logger = logger.child({ context: "bentocache.bus" }); this.#bus = new BoringBus(driver, { retryQueue: { ...options.retryQueue, removeDuplicates: true, retryInterval: options.retryQueue?.retryInterval ?? 2e3 } }); if (name) this.#channelName += `:${name}`; this.#bus.subscribe(this.#channelName, this.#onMessage.bind(this)); this.#logger.trace({ channel: this.#channelName }, "bus subscribed to channel"); } /** * Add a LocalCache for this bus to manage * @param namespace The namespace * @param cache The LocalCache instance */ manageCache(namespace, cache) { this.#logger.trace({ namespace, channel: this.#channelName }, "added namespaced cache"); this.#localCaches?.set(namespace || "__default__", cache); } /** * When a message is received through the bus. * This is where we update the local cache. */ async #onMessage(message) { message.namespace = message.namespace || "__default__"; if (!message.namespace || !this.#localCaches.has(message.namespace)) return; this.#logger.trace({ ...message, channel: this.#channelName }, "received message from bus"); this.#emitter.emit("bus:message:received", busEvents.messageReceived(message).data); const cache = this.#localCaches.get(message.namespace); if (message.type === CacheBusMessageType.Delete) { for (const key of message.keys) cache?.delete(key); } if (message.type === CacheBusMessageType.Set) { for (const key of message.keys) cache?.logicallyExpire(key); } if (message.type === CacheBusMessageType.Expire) { for (const key of message.keys) cache?.logicallyExpire(key); } if (message.type === CacheBusMessageType.Clear) { cache?.clear(); } } /** * Publish a message to the bus channel * * @returns true if the message was published, false if not */ async publish(message) { const wasPublished = await this.#bus.publish(this.#channelName, message); if (wasPublished) { this.#emitter.emit("bus:message:published", busEvents.messagePublished(message).data); return true; } this.#logger.error("failed to publish message to bus"); return false; } /** * Disconnect the bus */ async disconnect() { await this.#bus.disconnect(); } }; // src/cache/cache_entry/cache_entry_options.ts import { is as is2 } from "@julr/utils/is"; var toId = hexoid(12); function resolveGrace(options) { if (options.grace === false) return 0; return resolveTtl(options.grace, null) ?? 0; } function createCacheEntryOptions(newOptions = {}, defaults = {}) { const options = { ...defaults, ...newOptions }; const grace = resolveGrace(options); const graceBackoff = resolveTtl(options.graceBackoff, null) ?? 0; let logicalTtl = resolveTtl(options.ttl); let physicalTtl = grace > 0 ? grace : logicalTtl; const timeout = resolveTtl(options.timeout, null); const hardTimeout = resolveTtl(options.hardTimeout, null); const lockTimeout = resolveTtl(options.lockTimeout, null); const forceFresh = options.forceFresh ?? false; const self = { /** * Unique identifier that will be used when logging * debug information. */ id: toId(), /** * Resolved grace period options */ grace, graceBackoff, /** * Logical TTL is when the value is considered expired * but still can be in the cache ( Grace period ) */ getLogicalTtl() { return logicalTtl; }, /** * Physical TTL is the time when value will be automatically * removed from the cache. This is the Grace period * duration */ getPhysicalTtl() { return physicalTtl; }, /** * Determine if the gracing system is enabled */ isGraceEnabled() { return grace > 0; }, /** * Timeouts for the cache operations */ timeout, hardTimeout, /** * Tags to associate with the cache entry */ tags: options.tags ?? [], /** * Skip options */ skipL2Write: options.skipL2Write ?? false, skipBusNotify: options.skipBusNotify ?? false, /** * Max time to wait for the lock to be acquired */ lockTimeout, onFactoryError: options.onFactoryError ?? defaults.onFactoryError, suppressL2Errors: options.suppressL2Errors, /** * Force fresh option */ forceFresh, /** * Returns a new instance of `CacheItemOptions` with the same * options as the current instance, but with any provided * options overriding the current * * For performance reasons, if no options are provided, the * current instance is returned */ cloneWith(newOptions2) { return newOptions2 ? createCacheEntryOptions(newOptions2, options) : self; }, /** * Set a new logical TTL */ setLogicalTtl(newTtl) { options.ttl = newTtl; logicalTtl = resolveTtl(options.ttl); physicalTtl = self.isGraceEnabled() ? grace : logicalTtl; return self; }, /** * Compute the logical TTL timestamp from now */ logicalTtlFromNow() { if (!logicalTtl) return; return Date.now() + logicalTtl; }, /** * Compute the physical TTL timestamp from now */ physicalTtlFromNow() { if (!physicalTtl) return; return Date.now() + physicalTtl; }, /** * Compute the lock timeout we should use for the * factory */ factoryTimeout(hasFallbackValue) { if (hasFallbackValue && self.isGraceEnabled() && is2.number(timeout)) { return { type: "soft", duration: timeout, exception: errors.E_FACTORY_SOFT_TIMEOUT }; } if (hardTimeout) { return { type: "hard", duration: hardTimeout, exception: errors.E_FACTORY_HARD_TIMEOUT }; } }, /** * Determine if we should use the SWR strategy */ shouldSwr(hasFallback) { return self.isGraceEnabled() && timeout === 0 && hasFallback; }, /** * Compute the maximum time we should wait for the * lock to be acquired */ getApplicableLockTimeout(hasFallbackValue) { if (self.shouldSwr(hasFallbackValue)) return 0; if (lockTimeout) return lockTimeout; if (hasFallbackValue && self.isGraceEnabled() && typeof timeout === "number") { return timeout; } } }; return self; } // src/cache/tag_system.ts var TagSystem = class { constructor(stack) { this.stack = stack; } #getSetHandler; #kTagPrefix = "___bc:t:"; #expireOptions = createCacheEntryOptions({}); #getSetTagOptions = createCacheEntryOptions({ ttl: "10d", grace: "10d" }); setGetSetHandler(handler) { this.#getSetHandler = handler; } /** * Get the cache key for a tag */ getTagCacheKey(tag) { return this.#kTagPrefix + tag; } /** * Check if a key is a tag key */ isTagKey(key) { return key.startsWith(this.#kTagPrefix); } /** * The GetSet factory when getting a tag from the cache. */ #getTagFactory(ctx) { const result = ctx.gracedEntry?.value ?? 0; if (result === 0) ctx.setOptions({ skipBusNotify: true, skipL2Write: true }); return result; } /** * Check if an entry is invalidated by a tag and return true if it is. */ async isTagInvalidated(entry) { if (!entry) return; if (this.isTagKey(entry.getKey())) return false; const tags = entry.getTags(); if (!tags.length) return false; for (const tag of tags) { const tagExpiration = await this.#getSetHandler.handle( this.getTagCacheKey(tag), this.#getTagFactory, this.#getSetTagOptions.cloneWith({}) ); if (entry.getCreatedAt() <= tagExpiration) { await this.stack.expire(entry.getKey(), this.#expireOptions); return true; } } } /** * Create invalidation keys for a list of tags * * We write a `__bc:t:<tag>` key with the current timestamp as value. * When we check if a key is invalidated by a tag, we check if the key * was created before the tag key value. */ async createTagInvalidations(tags) { const now = Date.now(); for (const tag of new Set(tags)) { const key = this.getTagCacheKey(tag); await this.stack.set(key, now, this.#getSetTagOptions); } return true; } }; // src/cache/cache_entry/cache_entry.ts var CacheEntry = class _CacheEntry { /** * The key of the cache item. */ #key; /** * The value of the item. */ #value; #tags; /** * The logical expiration is the time in miliseconds when the item * will be considered expired. But, if grace period is enabled, * the item will still be available for a while. */ #logicalExpiration; /** * The time when the item was created. */ #createdAt; #serializer; constructor(key, item, serializer) { this.#key = key; this.#value = item.value; this.#tags = item.tags ?? []; this.#logicalExpiration = item.logicalExpiration; this.#serializer = serializer; this.#createdAt = item.createdAt; } getValue() { return this.#value; } getKey() { return this.#key; } getCreatedAt() { return this.#createdAt; } getLogicalExpiration() { return this.#logicalExpiration; } getTags() { return this.#tags; } isLogicallyExpired() { return Date.now() >= this.#logicalExpiration; } static fromDriver(key, item, serializer) { if (!serializer && typeof item !== "string") return new _CacheEntry(key, item, serializer); return new _CacheEntry(key, serializer.deserialize(item) ?? item, serializer); } applyBackoff(duration) { this.#logicalExpiration += duration; return this; } expire() { this.#logicalExpiration = Date.now() - 100; return this; } serialize() { const raw = { value: this.#value, createdAt: this.#createdAt, logicalExpiration: this.#logicalExpiration, ...this.#tags.length > 0 && { tags: this.#tags } }; if (this.#serializer) return this.#serializer.serialize(raw); return raw; } }; // src/cache/facades/local_cache.ts var LocalCache = class { #driver; #logger; #serializer; constructor(driver, logger, serializer) { this.#driver = driver; this.#serializer = serializer; this.#logger = logger.child({ layer: "l1" }); } /** * Get an item from the local cache */ get(key, options) { this.#logger.trace({ key, opId: options.id }, "try getting from l1 cache"); const value = this.#driver.get(key); if (value === void 0) { this.#logger.debug({ key, opId: options.id }, "cache miss"); return; } const entry = CacheEntry.fromDriver(key, value, this.#serializer); const isGraced = entry.isLogicallyExpired(); if (isGraced) { this.#logger.debug({ key, opId: options.id }, "cache hit (graced)"); } else { this.#logger.debug({ key, opId: options.id }, "cache hit"); } return { entry, isGraced }; } /** * Set a new item in the local cache */ set(key, value, options) { const physicalTtl = options.getPhysicalTtl(); if (!options.isGraceEnabled() && physicalTtl && physicalTtl <= 0) { return this.delete(key, options); } this.#logger.debug({ key, opId: options.id }, "saving item"); this.#driver.set(key, value, physicalTtl); } /** * Delete an item from the local cache */ delete(key, options) { this.#logger.debug({ key, opId: options?.id }, "deleting item"); return this.#driver.delete(key); } /** * Delete many item from the local cache */ deleteMany(keys, options) { this.#logger.debug({ keys, options, opId: options.id }, "deleting items"); this.#driver.deleteMany(keys); } /** * Make an item logically expire in the local cache */ logicallyExpire(key, options) { this.#logger.debug({ key, opId: options?.id }, "logically expiring item"); const value = this.#driver.get(key); if (value === void 0) return; const newEntry = CacheEntry.fromDriver(key, value, this.#serializer).expire().serialize(); return this.#driver.set(key, newEntry, this.#driver.getRemainingTtl(key)); } /** * Create a new namespace for the local cache */ namespace(namespace) { return this.#driver.namespace(namespace); } /** * Clear the local cache */ clear() { return this.#driver.clear(); } /** * Disconnect from the local cache */ disconnect() { return this.#driver.disconnect(); } }; // src/cache/facades/remote_cache.ts import { is as is3 } from "@julr/utils/is"; // src/circuit_breaker/index.ts import { InvalidArgumentsException } from "@poppinss/exception"; var CircuitBreakerState = { Closed: 0, Open: 1 }; var CircuitBreaker = class { #state = CircuitBreakerState.Closed; #willCloseAt = null; #breakDuration; constructor(options) { this.#breakDuration = options.breakDuration ?? 0; if (this.#breakDuration < 0) { throw new InvalidArgumentsException("breakDuration must be a positive number"); } this.#state = CircuitBreakerState.Closed; } /** * Check if the circuit breaker should change state */ #checkState() { if (this.#willCloseAt && this.#willCloseAt < Date.now()) this.close(); } /** * Check if the circuit breaker is open */ isOpen() { this.#checkState(); return this.#state === CircuitBreakerState.Open; } /** * Check if the circuit breaker is closed */ isClosed() { this.#checkState(); return this.#state === CircuitBreakerState.Closed; } /** * Open the circuit breaker */ open() { if (this.#state === CircuitBreakerState.Open) return; this.#state = CircuitBreakerState.Open; this.#willCloseAt = Date.now() + this.#breakDuration; } /** * Close the circuit breaker */ close() { this.#state = CircuitBreakerState.Closed; this.#willCloseAt = null; } }; // src/cache/facades/remote_cache.ts var RemoteCache = class { #driver; #logger; #hasL1Backup; #circuitBreaker; #options; constructor(driver, logger, hasL1Backup, options) { this.#driver = driver; this.#options = options; this.#hasL1Backup = hasL1Backup; this.#circuitBreaker = options.l2CircuitBreakerDuration ? new CircuitBreaker({ breakDuration: options.l2CircuitBreakerDuration }) : void 0; this.#logger = logger.child({ layer: "l2" }); } /** * Try to execute a cache operation and fallback to a default value * if the operation fails */ async #tryCacheOperation(operation, options, fallbackValue, fn) { if (this.#circuitBreaker?.isOpen()) { this.#logger.error({ opId: options.id }, `circuit breaker is open. ignoring operation`); return fallbackValue; } try { return await fn(); } catch (err) { this.#logger.error({ err, opId: options.id }, `(${operation}) failed on remote cache`); this.#circuitBreaker?.open(); if (is3.undefined(options.suppressL2Errors) && this.#hasL1Backup || options.suppressL2Errors) { return fallbackValue; } throw new errors.E_L2_CACHE_ERROR(err); } } /** * Get an item from the remote cache */ async get(key, options) { return await this.#tryCacheOperation("get", options, void 0, async () => { const value = await this.#driver.get(key); if (value === void 0) return; const entry = CacheEntry.fromDriver(key, value, this.#options.serializer); const isGraced = entry.isLogicallyExpired(); if (isGraced) { this.#logger.debug({ key, opId: options.id }, "cache hit (graced)"); } else { this.#logger.debug({ key, opId: options.id }, "cache hit"); } return { entry, isGraced }; }); } /** * Set a new item in the remote cache */ async set(key, value, options) { return await this.#tryCacheOperation("set", options, false, async () => { this.#logger.debug({ key, opId: options.id }, "saving item"); await this.#driver.set(key, value, options.getPhysicalTtl()); return true; }); } /** * Delete an item from the remote cache */ async delete(key, options) { return await this.#tryCacheOperation("delete", options, false, async () => { this.#logger.debug({ key, opId: options.id }, "deleting item"); return await this.#driver.delete(key); }); } /** * Delete multiple items from the remote cache */ async deleteMany(keys, options) { return await this.#tryCacheOperation("deleteMany", options, false, async () => { this.#logger.debug({ keys, opId: options.id }, "deleting items"); return await this.#driver.deleteMany(keys); }); } /** * Make an item logically expire in the remote cache */ async logicallyExpire(key, options) { return await this.#tryCacheOperation("logicallyExpire", options, false, async () => { this.#logger.debug({ key, opId: options.id }, "logically expiring item"); const value = await this.#driver.get(key); if (value === void 0) return; const entry = CacheEntry.fromDriver(key, value, this.#options.serializer).expire().serialize(); return await this.#driver.set(key, entry, options.getPhysicalTtl()); }); } /** * Create a new namespace for the remote cache */ namespace(namespace) { return this.#driver.namespace(namespace); } /** * Clear the remote cache */ async clear(options) { return await this.#tryCacheOperation("clear", options, false, async () => { return await this.#driver.clear(); }); } /** * Manually prune expired cache entries */ prune() { return this.#driver.prune?.() ?? Promise.resolve(); } /** * Disconnect from the remote cache */ disconnect() { return this.#driver.disconnect(); } }; // src/cache/cache_stack.ts var CacheStack = class _CacheStack extends BaseDriver { constructor(name, options, drivers, bus) { super(options); this.name = name; this.options = options; this.logger = options.logger.child({ cache: this.name }); if (drivers.l1Driver) this.l1 = new LocalCache( drivers.l1Driver, this.logger, this.options.serializeL1 ? this.options.serializer : void 0 ); if (drivers.l2Driver) this.l2 = new RemoteCache(drivers.l2Driver, this.logger, !!this.l1, this.options); this.bus = bus ? bus : this.#createBus(drivers.busDriver, drivers.busOptions); if (this.l1) this.bus?.manageCache(this.prefix, this.l1); this.#tagSystem = new TagSystem(this); this.defaultOptions = createCacheEntryOptions(this.options); } l1; l2; bus; defaultOptions; logger; #busDriver; #busOptions; #tagSystem; #namespaceCache = /* @__PURE__ */ new Map(); get emitter() { return this.options.emitter; } #createBus(busDriver, busOptions) { if (!busDriver) return; this.#busDriver = busDriver; this.#busOptions = { retryQueue: { enabled: true, maxSize: void 0 }, ...busOptions }; return new Bus(this.name, this.#busDriver, this.logger, this.emitter, this.#busOptions); } setTagSystemGetSetHandler(getSetHandler) { this.#tagSystem.setGetSetHandler(getSetHandler); } namespace(namespace) { if (!this.#namespaceCache.has(namespace)) { this.#namespaceCache.set( namespace, new _CacheStack( this.name, this.options.cloneWith({ prefix: this.createNamespacePrefix(namespace) }), { l1Driver: this.l1?.namespace(namespace), l2Driver: this.l2?.namespace(namespace) }, this.bus ) ); } return this.#namespaceCache.get(namespace); } /** * Publish a message to the bus channel * * @returns true if the message was published, false if not * and undefined if a bus is not part of the stack */ async publish(message, options) { if (options?.skipBusNotify) return; return this.bus?.publish({ ...message, namespace: this.prefix }); } emit(event) { return this.emitter.emit(event.name, event.data); } /** * Write a value in the cache stack * - Set value in local cache * - Set value in remote cache * - Publish a message to the bus * - Emit a CacheWritten event */ async set(key, value, options) { if (is4.undefined(value)) throw new UndefinedValueError(key); const rawItem = { value, logicalExpiration: options.logicalTtlFromNow(), tags: options.tags, createdAt: Date.now() }; const l1Item = this.options.serializeL1 ? this.options.serializer.serialize(rawItem) : rawItem; this.l1?.set(key, l1Item, options); let l2Success = false; if (this.l2 && options.skipL2Write !== true) { const l2Item = this.options.serializeL1 ? l1Item : this.options.serializer.serialize(rawItem); l2Success = await this.l2?.set(key, l2Item, options); } if (this.l2 && l2Success || !this.l2) { await this.publish({ type: CacheBusMessageType.Set, keys: [key] }, options); } this.emit(cacheEvents.written(key, value, this.name)); return true; } /** * Expire a key from the cache. * Entry will not be fully deleted but expired and * retained for the grace period if enabled. */ async expire(key, options) { this.l1?.logicallyExpire(key, options); await this.l2?.logicallyExpire(key, options); await this.publish({ type: CacheBusMessageType.Expire, keys: [key] }); this.emit(cacheEvents.expire(key, this.name)); return true; } /** * Check if an item is valid. * Valid means : * - Logically not expired ( not graced ) * - Not invalidated by a tag */ isEntryValid(item) { if (!item) return false; const isGraced = item?.isGraced === true; if (isGraced) return false; if (item.entry.getTags().length === 0) return true; return this.#tagSystem.isTagInvalidated(item.entry).then((isTagInvalidated) => { return !isTagInvalidated; }); } /** * Create invalidation keys for a list of tags */ async createTagInvalidations(tags) { return this.#tagSystem.createTagInvalidations(tags); } }; // src/bento_cache_options.ts import EventEmitter from "events"; import { ms } from "@julr/utils/string/ms"; import { noopLogger } from "@julr/utils/logger"; // src/logger.ts var Logger = class _Logger { internalLogger; constructor(internalLogger) { this.internalLogger = internalLogger; } child(obj) { return new _Logger(this.internalLogger.child(obj)); } trace(msg, obj) { this.internalLogger.trace(msg, obj); } debug(msg, obj) { this.internalLogger.debug(msg, obj); } warn(msg, obj) { this.internalLogger.warn(msg, obj); } error(msg, obj) { this.internalLogger.error(msg, obj); } fatal(msg, obj) { this.internalLogger.fatal(msg, obj); } info(msg, obj) { this.internalLogger.info(msg, obj); } logMethod(options) { this.internalLogger.debug( { cacheName: options.cacheName, opId: options.options.id, key: options.key, tags: options.tags }, `'${options.method}' method called` ); } logL1Hit(options) { this.internalLogger.debug( { cacheName: options.cacheName, opId: options.options.id, key: options.key, graced: options.graced }, "memory hit" ); } logL2Hit(options) { this.internalLogger.debug( { cacheName: options.cacheName, opId: options.options.id, key: options.key, graced: options.graced }, "remote hit" ); } }; // src/serializers/json.ts var JsonSerializer = class { serialize(value) { return JSON.stringify(value); } deserialize(value) { return JSON.parse(value); } }; // src/bento_cache_options.ts var defaultSerializer = new JsonSerializer(); var BentoCacheOptions = class _BentoCacheOptions { #options; /** * The default TTL for all caches * * @default 30m */ ttl = ms.parse("30m"); /** * Default prefix for all caches */ prefix = "bentocache"; /** * The grace period options */ grace = false; graceBackoff = ms.parse("10s"); /** * Whether to suppress L2 cache errors */ suppressL2Errors; /** * The soft and hard timeouts for the factories */ timeout = 0; hardTimeout = null; /** * The logger used throughout the library */ logger; /** * The emitter used throughout the library */ emitter = new EventEmitter(); /** * Serializer to use for the cache */ serializer; /** * Max time to wait for the lock to be acquired */ lockTimeout = null; /** * Duration for the circuit breaker to stay open * if l2 cache fails */ l2CircuitBreakerDuration; /** * If the L1 cache should be serialized */ serializeL1 = true; onFactoryError; constructor(options) { this.#options = { ...this, ...options }; this.prefix = this.#options.prefix; this.ttl = this.#options.ttl; this.timeout = this.#options.timeout ?? 0; this.hardTimeout = this.#options.hardTimeout; this.suppressL2Errors = this.#options.suppressL2Errors; this.lockTimeout = this.#options.lockTimeout; this.grace = this.#options.grace; this.graceBackoff = this.#options.graceBackoff; this.emitter = this.#options.emitter; this.serializer = this.#options.serializer ?? defaultSerializer; this.l2CircuitBreakerDuration = resolveTtl(this.#options.l2CircuitBreakerDuration, null); this.logger = new Logger(this.#options.logger ?? noopLogger()); this.onFactoryError = this