syntropylog
Version:
An instance manager with observability for Node.js applications
319 lines • 12.7 kB
JavaScript
/**
* FILE: src/redis/RedisConnectionManager.ts
* DESCRIPTION: Manages the lifecycle of the Redis client connection.
*/
import { createClient } from 'redis';
// Type guard for single-node RedisClientType
function isRedisClientType(client) {
return (typeof client.ping === 'function' &&
!('commands' in client));
}
/**
* @class RedisConnectionManager
* Handles the state and lifecycle of a single native `node-redis` client.
* It abstracts away the complexities of connection states, retries, and events,
* providing a stable and predictable promise-based interface for connecting and disconnecting.
*/
export class RedisConnectionManager {
instanceName;
client;
logger;
connectionPromise = null;
connectionResolve = null;
connectionReject = null;
isConnectedAndReadyState = false;
isQuitState = false;
/**
* Constructs a new RedisConnectionManager.
* @param {RedisClientOptions | RedisClusterOptions} options - The configuration options for the native `redis` client.
* @param {ILogger} logger - The logger instance for logging connection events.
*/
constructor(config, logger) {
this.logger = logger;
this.instanceName = config.instanceName;
this.client = this.createNativeClient(config);
this.setupListeners();
}
/**
* Creates a native Redis client based on the instance configuration mode.
* @param config The configuration for the specific Redis instance.
* @returns A `NodeRedisClient` (either single-node or cluster).
*/
createNativeClient(config) {
switch (config.mode) {
case 'single':
case 'sentinel': {
// The reconnection strategy only applies to 'single' and 'sentinel' modes.
// It is defined here so TypeScript can correctly infer that 'config' has the 'retryOptions' property.
const reconnectStrategy = (retries) => {
const maxRetries = config.retryOptions?.maxRetries ?? 10;
if (retries > maxRetries) {
return new Error('Exceeded the maximum number of Redis connection retries.');
}
return Math.min(retries * 50, config.retryOptions?.retryDelay ?? 2000);
};
if (config.mode === 'single') {
return createClient({
url: config.url,
socket: {
reconnectStrategy,
},
});
}
else {
// An intermediate variable is created so that TypeScript correctly infers the overload.
const sentinelOptions = {
sentinels: config.sentinels,
name: config.name,
sentinelPassword: config.sentinelPassword,
socket: {
reconnectStrategy,
},
};
return createClient(sentinelOptions);
}
}
case 'cluster': {
// Reconnection in cluster mode is handled internally by the library.
// The variable is explicitly typed so that TypeScript uses the correct overload of `createClient`.
const clusterOptions = {
// Transforms the node configuration to the structure expected by the 'redis' library.
rootNodes: config.rootNodes.map((node) => ({
socket: { host: node.host, port: node.port },
})),
};
return createClient(clusterOptions);
}
default: {
const _exhaustiveCheck = config;
throw new Error(`Unsupported Redis mode: "${_exhaustiveCheck.mode}"`); // NOSONAR
}
}
}
/**
* Sets up all the necessary event listeners on the native Redis client
* to manage and report on the connection's lifecycle state.
* @private
*/
setupListeners() {
this.client.on('connect', () => this.logger.info(`Connection established.`));
this.client.on('ready', () => {
this.logger.info(`Client is ready.`);
this.isConnectedAndReadyState = true;
if (this.connectionResolve) {
this.connectionResolve();
this.connectionResolve = null;
this.connectionReject = null;
}
});
this.client.on('end', () => {
this.logger.warn(`Connection closed.`);
this.isConnectedAndReadyState = false;
});
this.client.on('error', (err) => {
this.logger.error(`Client Error.`, { error: err });
if (this.connectionReject) {
this.connectionReject(err);
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
}
});
this.client.on('reconnecting', () => {
this.logger.info(`Client is reconnecting...`);
});
}
/**
* Initiates a connection to the Redis server.
* This method is idempotent; it will not attempt to reconnect if already connected
* or if a connection attempt is already in progress.
* @returns {Promise<void>} A promise that resolves when the client is connected and ready, or rejects on a connection error.
*/
connect() {
if (this.isQuitState) {
return Promise.reject(new Error('Client has been quit and cannot be reconnected.'));
}
if (this.isReady()) {
return Promise.resolve();
}
if (this.connectionPromise) {
return this.connectionPromise;
}
this.logger.info(`Attempting to connect...`);
this.connectionPromise = new Promise((resolve, reject) => {
this.connectionResolve = resolve;
this.connectionReject = reject;
this.client.connect().catch((err) => {
this.logger.error(`Immediate connection attempt failed.`, {
error: err,
});
if (this.connectionReject) {
this.connectionReject(err);
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
}
});
});
return this.connectionPromise;
}
/**
* Ensures the client is connected and ready before proceeding.
* This is the primary method that should be awaited before executing a command.
* @returns {Promise<void>} A promise that resolves when the client is ready, or rejects if it can't connect.
*/
ensureReady() {
if (this.isQuitState) {
return Promise.reject(new Error('Client has been quit. Cannot execute commands.'));
}
if (!this.isReady() && !this.connectionPromise) {
this.logger.debug('ensureReady: Client not open, initiating connect.');
}
return this.connect();
}
/**
* Gracefully closes the connection to the Redis server by calling `quit()`.
* It also sets an internal state to prevent any further operations or reconnections.
* @returns {Promise<void>} A promise that resolves when the client has been successfully quit.
*/
async disconnect() {
if (this.isQuitState) {
this.logger.info('Quit already called. No action taken.');
return;
}
if (this.connectionReject) {
this.connectionReject(new Error('Connection aborted due to disconnect call.'));
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
}
this.isQuitState = true;
this.isConnectedAndReadyState = false;
if (this.client.isOpen) {
this.logger.info('Attempting to quit client.');
try {
await this.client.quit();
}
catch (error) {
this.logger.error('Error during client.quit().', { error });
throw error;
}
}
else {
this.logger.info('Client was not open. Quit operation effectively complete.');
}
}
/**
* Retrieves the underlying native `node-redis` client instance.
* @returns {NodeRedisClient} The native client instance.
*/
getNativeClient() {
return this.client;
}
/**
* Checks if the client is currently connected and ready to accept commands.
* @returns {boolean} `true` if the client is ready, `false` otherwise.
*/
isReady() {
return this.isConnectedAndReadyState;
}
/**
* Performs a health check by sending a PING command to the server.
* @returns {Promise<boolean>} A promise that resolves to `true` if the server responds correctly, `false` otherwise.
*/
async isHealthy() {
if (this.isQuitState || !this.isReady()) {
return false;
}
try {
// By calling this.ping(), we reuse the logic that correctly handles
// single-node and cluster clients.
const pong = await this.ping();
this.logger.debug(`PING response: ${pong}`);
return pong === 'PONG';
}
catch (error) {
this.logger.error(`PING failed during health check.`, { error });
return false;
}
}
/**
* Checks if the disconnect (`quit`) process has been initiated for this client.
* @returns {boolean} `true` if `disconnect` has been called, `false` otherwise.
*/
isQuit() {
return this.isQuitState;
}
/**
* Executes the Redis PING command.
* Provides a fallback for cluster mode, as PING is not a standard cluster command.
*/
async ping(message) {
// First, we ensure the client is ready to receive commands.
await this.ensureReady();
// We use the type guard to check if it's a single-node or sentinel client.
if (isRedisClientType(this.client)) {
return this.client.ping(message);
}
// If it's a cluster client, we simulate the response as the library does.
return Promise.resolve(message || 'PONG');
}
/**
* Executes the Redis INFO command.
* Provides a fallback for cluster mode.
*/
async info(section) {
// We ensure the client is ready.
await this.ensureReady();
// Again, we use the type guard.
if (isRedisClientType(this.client)) {
return this.client.info(section);
}
// The INFO command does not exist in cluster mode.
return Promise.resolve('# INFO command is not supported in cluster mode.');
}
/**
* Executes the Redis EXISTS command.
* @param {string | string[]} keys - A single key or an array of keys to check.
* @returns {Promise<number>} A promise that resolves with the number of existing keys.
*/
async exists(keys) {
await this.ensureReady();
// The .exists() command is supported by both single-node and cluster clients.
return this.client.exists(keys);
}
/**
* Executes the Redis GET command.
* @param {string} key - The key to retrieve.
* @returns {Promise<string | null>} A promise that resolves with the value or null if not found.
*/
async get(key) {
await this.ensureReady();
return this.client.get(key);
}
/**
* Executes the Redis SET command.
* @param {string} key - The key to set.
* @param {string} value - The value to set.
* @param {number} [ttl] - Optional TTL in seconds.
* @returns {Promise<string>} A promise that resolves with 'OK' on success.
*/
async set(key, value, ttl) {
await this.ensureReady();
if (ttl) {
return this.client.setEx(key, ttl, value);
}
const result = await this.client.set(key, value);
return result || 'OK';
}
/**
* Executes the Redis DEL command.
* @param {string} key - The key to delete.
* @returns {Promise<number>} A promise that resolves with the number of keys deleted.
*/
async del(key) {
await this.ensureReady();
return this.client.del(key);
}
}
//# sourceMappingURL=RedisConnectionManager.js.map