@jonaskahn/maestro
Version:
Job orchestration made simple for Node.js message workflows
275 lines (248 loc) • 8.46 kB
JavaScript
/**
* @license
* Copyleft (c) 2025 Jonas Kahn. All rights are not reserved.
*
* This source code is licensed under the MIT License found in the
* LICENSE file in the root directory of this source tree.
*
* Redis Cache Implementation
*
* Concrete implementation of AbstractCache using Redis as the backend.
* Provides Redis-specific methods for key-value operations, TTL management,
* and pattern-based key scanning with standardized error handling and logging.
* Supports Redis connection pooling, retry strategies, and reconnection logic.
*/
const AbstractCache = require("../../abstracts/abstract-cache");
const redis = require("redis");
const logger = require("../../services/logger-service");
/**
* Redis Cache Client
*
* Implements the AbstractCache interface using Redis as the backing store.
* Handles connection management, retry strategies, and provides Redis-specific
* implementations of the cache operations defined in the abstract class.
*/
class RedisCacheClient extends AbstractCache {
/**
* Creates a new Redis cache client instance
*
* @param {Object} config - Cache configuration object
* @param {string} config.keyPrefix - Required prefix for all cache keys
* @param {Object} [config.connectionOptions] - Redis-specific connection options
* @param {string} [config.connectionOptions.url] - Redis connection URL
* @param {string} [config.connectionOptions.password] - Redis password
* @param {number} [config.processingTtl] - TTL for processing state keys
* @param {number} [config.suppressionTtl] - TTL for suppression keys
*/
constructor(config) {
super(config);
this._client = this.#createRedisClient();
}
/**
* Creates a Redis client with configured options
* @returns {Object} Redis client instance
*/
#createRedisClient() {
const connectionOptions = this.config.connectionOptions || {};
let redisUrl = "redis://localhost:6379";
if (process.env.MO_REDIS_URL) {
redisUrl = `redis://${process.env.REDIS_URL.trim().replace("redis://")}`;
} else {
redisUrl = `redis://${process.env.MO_REDIS_HOST?.trim() || "127.0.0.1"}:${process.env.MO_REDIS_PORT || 6379}`;
}
const clientConfig = {
url: connectionOptions.url || process.env.MO_REDIS_URL || redisUrl,
password: connectionOptions.password || process.env.MO_REDIS_PASSWORD,
retry_strategy: this.#createRetryStrategy(),
socket: {
reconnectStrategy: this.#createReconnectionStrategy(),
},
...connectionOptions,
};
const client = redis.createClient(clientConfig);
this.#attachEventHandlers(client);
return client;
}
/**
* Creates retry strategy for Redis client
* @returns {Function} Retry strategy callback
*/
#createRetryStrategy() {
const maxRetryAttempts = parseInt(process.env.MO_REDIS_MAX_RETRY_ATTEMPTS) || 5;
const retryDelayMs = parseInt(process.env.MO_REDIS_DELAY_MS) || 1000;
const maxDelayMs = parseInt(process.env.MO_REDIS_MAX_DELAY_MS) || 30000;
return attemptNumber => {
if (attemptNumber > maxRetryAttempts) {
logger.logError(`Redis retry limit exceeded (${attemptNumber} attempts)`);
return null;
}
const delayMs = Math.min(attemptNumber * retryDelayMs, maxDelayMs);
logger.logInfo(`Redis retry attempt ${attemptNumber} in ${delayMs}ms`);
return delayMs;
};
}
/**
* Creates reconnection strategy for Redis socket
* @returns {Function} Reconnection strategy callback
*/
#createReconnectionStrategy() {
const maxRetryAttempts = parseInt(process.env.MO_REDIS_MAX_RETRY_ATTEMPTS) || 5;
const retryDelayMs = parseInt(process.env.MO_REDIS_DELAY_MS) || 1000;
const maxDelayMs = parseInt(process.env.MO_REDIS_MAX_DELAY_MS) || 30000;
return retryAttemptNumber => {
if (retryAttemptNumber > maxRetryAttempts) {
logger.logError(`Redis reconnection limit exceeded (${retryAttemptNumber} attempts)`);
return new Error("Redis connection failed permanently");
}
const delayMs = Math.min(retryAttemptNumber * retryDelayMs, maxDelayMs);
logger.logInfo(`Redis reconnecting in ${delayMs}ms (attempt ${retryAttemptNumber})`);
return delayMs;
};
}
/**
* Attaches event handlers to Redis client
* @param {Object} client - Redis client instance
*/
#attachEventHandlers(client) {
client.on("error", error => {
logger.logError("Redis client error occurred", error);
});
client.on("connect", () => {
logger.logConnectionEvent("Redis", "client connecting to server");
});
client.on("ready", () => {
logger.logConnectionEvent("Redis", "client ready for operations");
});
client.on("end", () => {
logger.logConnectionEvent("Redis", "client disconnected from server");
});
client.on("reconnecting", () => {
logger.logConnectionEvent("Redis", "client reconnecting to server");
});
}
/**
* Checks if a Redis connection already exists
* @returns {boolean} True if connection exists
*/
_checkExistingConnection() {
return this._client && this._client.isOpen;
}
/**
* Connects to Redis server
* @returns {Promise<void>}
*/
async _connectTo() {
if (!this._client.isOpen) {
await this._client.connect();
}
}
/**
* Disconnects from Redis server
* @returns {Promise<void>}
*/
async _disconnectFrom() {
if (this._client.isOpen) {
await this._client.disconnect();
}
}
/**
* Sets a value in Redis with optional TTL
* @param {string} key - Cache key
* @param {any} value - Value to store
* @param {number} ttlMs - Time-to-live in milliseconds
* @returns {Promise<boolean>} Success indicator
*/
async _setKeyValue(key, value, ttlMs) {
if (ttlMs) {
const ttlSeconds = Math.ceil(ttlMs / 1000);
await this._client.set(key, value, {
EX: ttlSeconds,
});
} else {
await this._client.set(key, value);
}
return true;
}
/**
* Gets a value from Redis
* @param {string} key - Cache key
* @returns {Promise<any>} Retrieved value or null
*/
async _getKeyValue(key) {
return await this._client.get(key);
}
/**
* Deletes a key from Redis
* @param {string} key - Cache key
* @returns {Promise<boolean>} True if key was deleted
*/
async _deleteKey(key) {
return await this._client.del(key);
}
/**
* Checks if key exists in Redis
* @param {string} key - Cache key
* @returns {Promise<boolean>} True if key exists
*/
async _checkKeyExists(key) {
return await this._client.exists(key);
}
/**
* Sets expiry for existing Redis key (converts from milliseconds to seconds)
* @param {string} key - Cache key
* @param {number} ttlMs - Time-to-live in milliseconds
* @returns {Promise<boolean>} True if expiry was set
*/
async _setKeyExpiry(key, ttlMs) {
const ttlSeconds = Math.ceil(ttlMs / 1000);
return await this._client.expire(key, ttlSeconds);
}
/**
* Finds keys matching a pattern using Redis scan
* @param {string} pattern - Pattern to match
* @returns {Promise<string[]>} Array of matching keys
*/
async _findKeysByPattern(pattern) {
try {
const sentKeys = [];
let scanCount = 0;
for await (const keys of this._client.scanIterator({
MATCH: pattern,
COUNT: 1000,
})) {
sentKeys.push(...keys);
scanCount++;
if (scanCount % 100 === 0) {
logger.logDebug(`Found ${scanCount} keys in Redis scan operation`);
}
}
return sentKeys;
} catch (error) {
logger.logError(`Error scanning Redis keys with pattern '${pattern}'`, error);
throw error;
}
}
/**
* Sets a key with TTL only if it doesn't exist (atomic NX operation)
* @param {string} key - Cache key
* @param {any} value - Value to store
* @param {number} ttlMs - Time-to-live in milliseconds
* @returns {Promise<boolean>} True if key was set
*/
async _setKeyIfNotExists(key, value, ttlMs) {
let result;
if (ttlMs) {
const ttlSeconds = Math.ceil(ttlMs / 1000);
result = await this._client.set(key, value, {
EX: ttlSeconds,
NX: true,
});
} else {
result = await this._client.set(key, value, {
NX: true,
});
}
return result === "OK";
}
}
module.exports = RedisCacheClient;