bentocache
Version:
Multi-tier cache module for Node.js. Redis, Upstash, CloudfareKV, File, in-memory and others drivers
1,613 lines (1,589 loc) • 56.5 kB
JavaScript
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 extends Exception {
static code = "E_FACTORY_HARD_TIMEOUT";
static message = "Factory has timed out after waiting for hard timeout";
key;
constructor(key) {
super();
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 = 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 (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.#options.onFactoryError;
}
serializeL1Cache(shouldSerialize = true) {
this.serializeL1 = shouldSerialize;
return this;
}
cloneWith(optio