@nasriya/cachify
Version:
A lightweight, extensible in-memory caching library for storing anything, with built-in TTL and customizable cache types.
306 lines (305 loc) • 14.6 kB
JavaScript
import cachify from "../../cachify.js";
import EngineError from "./EngineError.js";
import { TasksQueue } from "@nasriya/atomix/tools";
class EnginesProxy {
#_queues = new Map();
#_leachers = new Map();
#_engines;
constructor(engines) { this.#_engines = engines; }
#_helpers = {
getTaskQueue: (key) => {
const queue = this.#_queues.get(key);
if (queue) {
return queue;
}
const newQueue = new TasksQueue({ autoRun: true });
this.#_queues.set(key, newQueue);
return newQueue;
},
getLeachersMap: (key) => {
const leachers = this.#_leachers.get(key);
if (leachers) {
return leachers;
}
const newLeachers = [];
this.#_leachers.set(key, newLeachers);
return newLeachers;
},
respondToLeachers: (op, result) => {
const leacherKey = `${op}_${result.record.key}`;
if (!this.#_leachers.has(leacherKey)) {
return;
}
// Take a copy of the leachers array
const leachers = this.#_leachers.get(leacherKey);
// Clears the leachers array for data consistency
this.#_leachers.delete(leacherKey);
// ✅ Debug log goes here
if (cachify.debug) {
console.debug(`[Cachify:Leachers] Resolving ${leachers.length} leacher(s) for "${leacherKey}" (${result.ok ? 'success' : 'error'})`);
}
// Respond to the leachers
for (const leacher of leachers) {
try {
if (result.ok === true) {
switch (op) {
case 'read': {
leacher.resolve(result.response);
}
case 'remove':
case 'set': {
leacher.resolve();
}
}
}
else {
leacher.reject(result.error);
}
}
catch (error) {
// Ignore or log the error if debugging
if (cachify.debug) {
console.debug(`[Cachify:Leachers] Failed to resolve leacher for "${leacherKey}":`, error);
}
}
}
}
};
/**
* Sets the value for a given cache record across multiple engines.
*
* This method attempts to set a value for a cache record in all specified engines.
* If any engine fails to set the value, it will attempt to remove the record
* from any engines where the set operation was successful, to maintain consistency.
*
* @param {CacheRecord} record - The cache record to set the value for.
* @param {any} value - The value to set for the cache record.
* @throws {Error} Throws an error if setting the value fails for one or more engines.
* The error contains a summary of which engines failed and why.
* @returns {Promise<void>} A promise that resolves when the value is set or rejects with an error if any engine fails.
*/
async set(record, value) {
const setPromises = [];
for (const engineName of record.engines) {
const engine = this.#_engines.getEngine(engineName);
setPromises.push(engine.set(record, value).then(() => engineName).catch((err) => Promise.reject({ engineName, error: err })));
}
const results = await Promise.allSettled(setPromises);
const errors = results.filter(i => i.status === 'rejected').map(i => i.reason);
if (errors.length > 0) {
const successfulEngines = results.filter(i => i.status === 'fulfilled').map(i => i.value);
await Promise.all(successfulEngines.map(engineName => {
const engine = this.#_engines.getEngine(engineName);
return engine.remove(record);
}));
const error = new Error(`Failed to set record "${record.key}"`);
error.cause = errors;
error.summary = errors.map(e => `- ${e.engineName}: ${e.error?.message || e.error}`).join('\n');
throw error;
}
}
/**
* Attempts to remove a cache record from all engines associated with it.
*
* This method runs the `remove` operation across all engines listed in the
* record's `engines` array. If all engines fail to remove the record,
* an error is thrown containing details about each failure.
*
* If some engines succeed and others fail, the method completes without throwing,
* but logs a warning if `cachify.debug` is enabled.
*
* Each removal is handled independently using `Promise.allSettled` to ensure
* one failing engine does not block others.
*
* @param {CacheRecord} record - The cache record to remove from each engine.
* @returns {Promise<void>} Resolves if at least one engine successfully removes the record.
* Throws an error if all engines fail.
*
* @throws {Error} If all engine removals fail. The thrown error includes a `.cause`
* array listing all individual engine errors, and a `.summary` string for human-readable output.
*
* @example
* try {
* await engineProxy.remove(record);
* } catch (error) {
* console.error('Failed to remove record from all engines:', error.summary);
* }
*
* @since v1.0.0
*/
async remove(record) {
// Defensive: If already gone, return immediately
if (!record) {
return;
}
const queue = this.#_helpers.getTaskQueue(record.key);
const taskId = `remove_${record.key}`;
if (queue.hasTask(taskId)) {
const leacherKey = `remove_${record.key}`;
const leachers = this.#_helpers.getLeachersMap(leacherKey);
return new Promise((resolve, reject) => {
leachers.push({
resolve: () => resolve(),
reject: (error) => reject(error)
});
});
}
return new Promise((resolve, reject) => {
queue.addTask({
id: taskId,
type: 'remove',
priority: 1,
action: async () => {
const debug = cachify.debug;
const removePromises = record.engines.map(engineName => {
const engine = this.#_engines.getEngine(engineName);
return new Promise((engineResolver, engineRejecter) => {
engine.remove(record).then(() => engineResolver(engineName)).catch(err => {
if (debug)
console.debug(`Failed to remove from engine "${engineName}":`, err);
engineRejecter({ engineName, error: err });
});
});
});
const results = await Promise.allSettled(removePromises);
const errors = results.filter(i => i.status === 'rejected').map(i => i.reason);
const allFailed = errors.length === record.engines.length;
const summary = errors.length > 0 ? errors.map(e => {
return `- ${e.engineName}: ${e.error?.message || e.error}`;
}).join('\n') : '';
if (allFailed) {
const error = new EngineError(`Failed to remove record "${record.key}" from all engines.`);
error.errors = errors;
error.cause = summary;
throw error;
}
if (errors.length > 0 && debug) {
console.debug(`Failed to remove record "${record.key}" from engines:\n${summary}`);
}
},
onResolve: () => {
this.#_helpers.respondToLeachers('remove', { ok: true, record });
resolve();
},
onReject: (err) => {
this.#_helpers.respondToLeachers('remove', { ok: false, error: err, record });
reject(err);
}
});
});
}
/**
* Retrieves the value associated with the given cache record key.
* This method attempts to read the value from all specified engines and
* returns the value from the first engine that responds with a value.
* If all engines respond with undefined, it returns a response with
* undefined as the value.
* If any engine fails to read the value, it will attempt to read the
* value from other engines and return the value from the first engine
* that responds with a value. If all engines fail to read the value, it
* throws an error with a summary of which engines failed and why.
*
* @param record - The cache record to retrieve the value for.
* @returns A promise that resolves with the value associated with the cache record.
* @throws {Error} Throws an error if reading the value fails for one or more engines.
* The error contains a summary of which engines failed and why.
* @since v1.0.0
*/
async read(record) {
const queue = this.#_helpers.getTaskQueue(record.key);
const taskId = `read_${record.key}`;
if (queue.hasTask(taskId)) {
const leacherKey = `read_${record.key}`;
const leachers = this.#_helpers.getLeachersMap(leacherKey);
return new Promise((resolve, reject) => {
leachers.push({
resolve: (response) => resolve(response),
reject: (error) => reject(error)
});
});
}
return new Promise((resolve, reject) => {
queue.addTask({
id: `read_${record.key}`,
type: 'read',
priority: 0,
action: async () => {
const debug = cachify.debug;
const hasMemoryEngine = record.engines.includes('memory');
if (hasMemoryEngine) {
const memoryEngine = this.#_engines.getEngine('memory');
const value = await memoryEngine.read(record);
if (debug) {
console.debug(`Read from memory engine:`, value);
}
if (value !== undefined) {
return { source: 'memory', value };
}
}
const readPromises = record.engines.filter(engineName => engineName !== 'memory').map(engineName => {
const engine = this.#_engines.getEngine(engineName);
return new Promise((engineResolver, engineRejecter) => {
engine.read(record).then(value => {
if (value !== undefined) {
engineResolver({ source: engineName, value });
}
else {
if (debug)
console.debug(`Engine "${engineName}" returned undefined.`);
const error = new EngineError(`Engine "${engineName}" returned undefined.`);
error.cause = 'ENGINE_UNDEFINED_VALUE';
engineRejecter(error);
}
}).catch(err => {
if (debug)
console.debug(`Failed to read from engine "${engineName}":`, err);
const error = new EngineError(`Failed to read from engine "${engineName}": ${err.message || err}`);
error.errors = [err];
engineRejecter(error);
});
});
});
try {
const response = await Promise.any(readPromises);
if (debug) {
console.debug(`Read from engine "${response.source}":`, response.value);
}
return response;
}
catch (mainError) {
if (mainError instanceof AggregateError) {
const errors = mainError.errors;
const isUndefinedValueError = (err) => err instanceof EngineError && err.cause === 'ENGINE_UNDEFINED_VALUE';
const undefinedValueErrors = errors.filter(isUndefinedValueError);
if (undefinedValueErrors.length === errors.length) {
if (debug)
console.debug(`All engines returned undefined.`);
const response = { source: 'proxy', value: undefined };
return resolve(response);
}
const filteredErrors = errors.filter(err => !isUndefinedValueError(err));
const aggregate = new AggregateError(filteredErrors, `Failed to read record "${record.key}": all engines failed.`);
throw aggregate;
}
throw mainError;
}
},
onResolve: (res) => {
this.#_helpers.respondToLeachers('read', { ok: true, response: res, record });
resolve(res);
},
onReject: (err) => {
this.#_helpers.respondToLeachers('read', { ok: false, error: err, record });
reject(err);
}
});
});
}
/**
* Retrieves the list of engines associated with the proxy.
* @returns The list of engines associated with the proxy.
* @since v1.0.0
*/
get engines() { return this.#_engines; }
}
export default EnginesProxy;