redis-csc
Version:
A client-side caching wrapper for ioredis utilizing Redis 6+ CLIENT TRACKING feature.
784 lines (702 loc) • 40.6 kB
JavaScript
function _log(...args) {
//console.log(...args);
}
/**
* Helper function to wait for ioredis client readiness.
* @private
* @param {import('ioredis').Redis} client - The ioredis client instance.
* @param {number} [timeoutMs=5000] - Timeout in milliseconds.
* @returns {Promise<void>} Resolves when ready, rejects on error or timeout.
*/
function _waitUntilReady(client, timeoutMs = 50000) {
return new Promise((resolve, reject) => {
if (!client || typeof client.status !== 'string' || typeof client.connect !== 'function') {
return reject(new Error("Invalid ioredis client provided to _waitUntilReady."));
}
if (client.status === 'ready') {
return resolve();
}
if (client.status === 'end') {
return reject(new Error("ioredis client has already ended."));
}
let readyListener = null;
let errorListener = null;
let endListener = null;
let timeout = null;
const cleanup = () => {
if (readyListener) client.removeListener('ready', readyListener);
if (errorListener) client.removeListener('error', errorListener);
if (endListener) client.removeListener('end', endListener); // Handle client ending unexpectedly
if (timeout) clearTimeout(timeout);
};
// Listener for the 'ready' event
readyListener = () => {
_log(`_waitUntilReady: Client status became 'ready'.`); // Debug log
cleanup();
resolve();
};
// Listener for the 'error' event during the wait period
errorListener = (err) => {
console.error(`_waitUntilReady: Client emitted 'error' while waiting:`, err); // Debug log
cleanup();
// Reject with a more specific error message
reject(new Error(`ioredis client connection error while waiting for ready: ${err.message}`));
};
// Listener for the 'end' event
endListener = () => {
console.warn(`_waitUntilReady: Client status became 'end' while waiting.`); // Debug log
cleanup();
reject(new Error("ioredis client ended unexpectedly while waiting for ready status."));
};
// Set a timeout for the readiness check
timeout = setTimeout(() => {
console.warn(`_waitUntilReady: Timeout (${timeoutMs}ms) waiting for ready. Current status: ${client.status}`); // Debug log
cleanup();
reject(new Error(`Timeout (${timeoutMs}ms) waiting for ioredis client to become ready. Current status: ${client.status}`));
}, timeoutMs);
// Attach the listeners
client.once('ready', readyListener);
client.once('error', errorListener);
client.once('end', endListener);
// If the client is not already connecting, trigger connection.
// ioredis usually connects automatically unless lazyConnect is true.
// This check might be redundant depending on ioredis setup, but ensures connection attempt.
if (client.status !== 'connecting' && client.status !== 'connect' && client.status !== 'reconnecting') {
_log(`_waitUntilReady: Client status is '${client.status}', explicitly calling connect().`); // Debug log
client.connect().catch(err => {
// This catch is important if connect() itself rejects immediately
console.error(`_waitUntilReady: Explicit connect() call failed:`, err);
// cleanup(); // Cleanup might already be called by error listener
reject(new Error(`ioredis client explicit connect() failed: ${err.message}`));
});
} else {
_log(`_waitUntilReady: Client status is '${client.status}', connection already in progress or established.`); // Debug log
}
});
}
/**
* Implements Redis 6+ Client-Side Caching for ioredis.
* Uses the CLIENT TRACKING command with BCAST mode and prefix filtering.
* Requires two Redis connections: one for commands, one dedicated subscriber for invalidation messages.
*/
class RedisClientSideCache {
// --- Private instance fields ---
#localCache = {}; // In-memory cache store
#localCacheKeyTTLMap = {}; //manage the age of local cache keys
#localCacheTTL = 3600; // Default TTL for local cache keys (60 minutes)
#prefixes = []; // Key prefixes to manage in the local cache
#clientCachingTriggered = false; // Flag to prevent duplicate setup
/** @type {import('ioredis').Redis | null} */ // JSDoc type hint for ioredis
#redisClient = null; // Primary client for GET/SET etc.
/** @type {import('ioredis').Redis | null} */ // JSDoc type hint for ioredis
#subscriber = null; // Dedicated client for PUBSUB invalidation messages
#defaultExpiry = 60 * 60 * 24 * 7; // Default expiry for keys set via mSetCached (1 week)
#messageListener = null; // Stores the bound message listener function for removal
#invalidationCallback = null; // User-provided callback for invalidation events
/**
* Private constructor: Use the static `create` method for instantiation.
* Assumes client is an ioredis instance.
* @param {import('ioredis').Redis} client The primary ioredis client instance.
* @param {string[]} prefixes Array of key prefixes to cache locally.
* @param {number} [defaultExpiry] Optional default expiry (in seconds).
* @param {Function} [listener] Optional callback for invalidated keys.
*/
constructor(client, prefixes, defaultExpiry, listener, cacheTTL) {
// Basic check for ioredis client characteristics - ensure it's a valid instance
if (!client || typeof client.duplicate !== 'function' || typeof client.subscribe !== 'function' || typeof client.call !== 'function') {
throw new Error("Provided client does not appear to be a valid ioredis client instance.");
}
this.#redisClient = client;
// Create a dedicated connection for subscribing to invalidation messages
// This prevents blocking the main client with PUBSUB commands.
this.#subscriber = client.duplicate();
this.#prefixes = prefixes;
this.#defaultExpiry = (typeof defaultExpiry === 'number' && defaultExpiry >= 0)
? defaultExpiry
: (60 * 60 * 24 * 7); // Default to 1 week
this.#invalidationCallback = listener;
this.#localCacheTTL = (typeof cacheTTL === 'number' && cacheTTL > 0) ? cacheTTL : this.#localCacheTTL;
// Add error handlers to the subscriber client immediately
this.#subscriber.on('error', (err) => {
console.error('RedisClientSideCache: Subscriber client error:', err);
});
}
/**
* Creates, initializes, and returns a ready-to-use RedisClientSideCache instance using ioredis.
* Waits for client connections to be ready and sets up CLIENT TRACKING for invalidation.
* IMPORTANT: Assumes the provided `client` is managed externally (e.g., connection errors, reconnection).
* This class manages the lifecycle of its internal `subscriber` client.
*
* @param {import('ioredis').Redis} client The primary ioredis client instance. Should be instantiated. Connection managed externally.
* @param {string[]} prefixes Array of key prefixes to cache locally. E.g., ['user:', 'product:']
* @param {number} [defaultExpiry] Optional default expiry for mSetCached (in seconds). Defaults to 1 week.
* @param {Function} [listener] Optional callback function invoked with invalidated keys array. Signature: (invalidatedKeys: string[]) => void
* @param {number} [readyTimeoutMs=5000] Timeout in milliseconds to wait for clients to connect initially.
* @param {number} [localCacheTTL=3600] Timeout in seconds for refreshing the cache.
* @returns {Promise<RedisClientSideCache>} A Promise resolving to the initialized RedisClientSideCache instance.
* @throws {Error} If initialization fails (e.g., client connection timeout, Redis command error).
*/
static async create(client, prefixes, defaultExpiry, listener, readyTimeoutMs = 5000, localCacheTTL = 300) {
if (!Array.isArray(prefixes)) {
throw new Error("prefixes must be an array of strings.");
}
// Basic check for ioredis client characteristics
if (!client || typeof client.status !== 'string' || typeof client.call !== 'function') {
throw new Error("A valid ioredis client instance must be provided.");
}
// Create the instance (this also duplicates the client for the subscriber)
const instance = new RedisClientSideCache(client, prefixes, defaultExpiry, listener, localCacheTTL);
try {
// 1. Wait for both clients to be ready.
// It's crucial because we need to send commands (CLIENT ID, CLIENT TRACKING).
// We wait for the main client *even though its lifecycle is external*
// because we need it ready for the initial TRACKING command.
_log("RedisClientSideCache: Waiting for main ioredis client to be ready...");
await _waitUntilReady(instance.#redisClient, readyTimeoutMs);
_log("RedisClientSideCache: Main ioredis client is ready.");
// Wait for the internally managed subscriber client
_log("RedisClientSideCache: Waiting for subscriber ioredis client to be ready...");
await _waitUntilReady(instance.#subscriber, readyTimeoutMs);
_log("RedisClientSideCache: Subscriber ioredis client is ready.");
// 2. Setup tracking and subscription logic
await instance.#setupTrackingAndSubscription();
_log("RedisClientSideCache: Instance successfully created and initialized.");
return instance;
} catch (err) {
console.error("RedisClientSideCache: Failed to initialize:", err);
// Attempt to clean up the *internally managed* subscriber client if creation failed
// Do NOT disconnect the main client here, as it's managed externally.
await instance.disconnectInternalResources(true); // Pass flag indicating partial state
// Rethrow the initialization error to the caller
throw new Error(`Failed to create RedisClientSideCache: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* @private
* Internal helper for setting up CLIENT TRACKING and PUBSUB subscription using ioredis methods.
* Assumes both #redisClient and #subscriber are connected and ready.
* @returns {Promise<void>}
* @throws {Error} If Redis commands fail.
*/
async #setupTrackingAndSubscription() {
if (this.#clientCachingTriggered) {
console.warn("RedisClientSideCache: Client tracking setup already triggered. Skipping.");
return;
}
// Redundant checks, but good practice before sending commands
if (this.#subscriber?.status !== 'ready') {
throw new Error("Internal Error: Subscriber client is not ready before setting up tracking.");
}
if (this.#redisClient?.status !== 'ready') {
throw new Error("Internal Error: Main client is not ready before setting up tracking.");
}
this.#clientCachingTriggered = true; // Set flag early to prevent race conditions
_log("RedisClientSideCache: Setting up Redis client-side caching tracking (BCAST mode)...");
try {
// 1. CRITICAL: Turn OFF any existing tracking for the main client on the Redis server.
// This prevents "ERR Prefix ... overlaps" if tracking wasn't cleaned up properly before.
_log("RedisClientSideCache: Sending CLIENT TRACKING OFF to main client to clear any existing tracking state...");
const offResult = await this.#redisClient.call('CLIENT', 'TRACKING', 'OFF');
_log(`RedisClientSideCache: CLIENT TRACKING OFF pre-setup result: ${offResult}`);
if (offResult !== 'OK') {
// Redis should ideally return 'OK' even if tracking was already off.
// Log a warning but proceed, as the subsequent TRACKING ON might still work or provide a clearer error.
console.warn(`RedisClientSideCache: CLIENT TRACKING OFF command on main client returned '${offResult}'. Expected 'OK'. Proceeding with caution.`);
}
// Get the Client ID of the subscriber connection. We redirect invalidation messages to this connection.
// Using .call() for commands not directly mapped by ioredis.
const clientId = await this.#subscriber.call('CLIENT', 'ID');
if (!clientId) {
throw new Error("Failed to get Client ID for the subscriber connection.");
}
_log(`RedisClientSideCache: Subscriber client ID: ${clientId}`);
// Construct the CLIENT TRACKING arguments
// ON: Enable tracking
// REDIRECT <clientId>: Send invalidation messages to this specific client ID (our subscriber)
// BCAST: Broadcast mode - track keys based on prefixes, even if not read by this client
// PREFIX <prefix>: Specify prefixes to track (repeat for multiple)
// NOLOOP: Don't send invalidations for changes made by this *main* client connection
const trackingArgs = ["ON", "REDIRECT", String(clientId)];
if (this.#prefixes.length > 0) {
trackingArgs.push("BCAST");
this.#prefixes.forEach(p => {
if (p) { // Ensure prefix is not empty
trackingArgs.push("PREFIX");
trackingArgs.push(p);
}
});
} else {
console.warn("RedisClientSideCache: No prefixes specified for BCAST tracking.");
return; // Skip BCAST if no prefixes are provided
}
trackingArgs.push("NOLOOP"); // Essential to avoid self-invalidation loops
_log("RedisClientSideCache: Sending CLIENT TRACKING command:", ["CLIENT", "TRACKING", ...trackingArgs].join(' '));
// Send the command using the *main* client connection
const trackingResult = await this.#redisClient.call("CLIENT", "TRACKING", ...trackingArgs);
_log("RedisClientSideCache: CLIENT TRACKING setup result:", trackingResult); // Should be 'OK'
if (trackingResult !== 'OK') {
throw new Error(`CLIENT TRACKING command failed. Result: ${trackingResult}`);
}
// --- Setup Subscriber ---
// Define the message listener function (bound to 'this' instance)
// This function will handle incoming invalidation messages.
this.#messageListener = (channel, message) => {
// We only care about the specific invalidation channel Redis sends messages to
// when using CLIENT TRACKING redirection.
if (channel !== '__redis__:invalidate') {
// Ignore messages from other channels
return;
}
if (!message) {
return;// Ignore empty messages
}
let msgArr = message.split(','); // redis sends keys as a comma-separated string
for (const key of msgArr) {
if (key && this.#qualifiesPrefix(key)) {
delete this.#localCache[key]; // Remove from local cache
delete this.#localCacheKeyTTLMap[key]; // Remove key age tracking
}
}
};
// Attach the listener to the 'message' event on the subscriber client
this.#subscriber.on('message', this.#messageListener);
// Subscribe the subscriber client to the invalidation channel.
// This channel is used by Redis when CLIENT TRACKING redirection is enabled.
await this.#subscriber.subscribe('__redis__:invalidate');
_log("RedisClientSideCache: Subscriber client successfully subscribed to '__redis__:invalidate' channel.");
} catch (err) {
console.error("RedisClientSideCache: Failed to set up client-side caching subscription internals:", err);
this.#clientCachingTriggered = false; // Reset flag on failure
// Cleanup: Remove listener if it was attached before the error occurred
if (this.#messageListener && this.#subscriber) {
try {
this.#subscriber.off('message', this.#messageListener);
} catch (offErr){ console.warn("RedisClientSideCache: Error removing message listener during setup failure cleanup", offErr); }
this.#messageListener = null;
}
// Attempt to unsubscribe if subscribe command was sent before error
if (this.#subscriber && typeof this.#subscriber.unsubscribe === 'function') {
try {
await this.#subscriber.unsubscribe('__redis__:invalidate');
} catch (unsubErr) { console.warn("RedisClientSideCache: Error unsubscribing during setup failure cleanup", unsubErr); }
}
// Rethrow the error to ensure the static `create` method fails clearly
throw err;
}
}
/**
* @private
* Checks if a key matches any of the configured prefixes.
* @param {string} key The key to check.
* @returns {boolean} True if the key starts with any of the prefixes, false otherwise.
*/
#qualifiesPrefix(key) {
if (typeof key !== 'string' || this.#prefixes.length === 0) {
return false; // Only cache strings, and only if prefixes are defined
}
return this.#prefixes.some(p => key.startsWith(p));
}
#setLocalValue(key, value, expiry) {
if (this.#qualifiesPrefix(key) === false) {
//console.log(`RedisClientSideCache: Key "${key}" does not match any prefixes. Not caching locally.`);
return; // Only cache keys that match the prefixes
}
//console.log(`RedisClientSideCache: Setting local cache for key "${key}" with value "${value}".`);
expiry = expiry && expiry > 0 ? expiry : this.#localCacheTTL; // Convert to milliseconds
this.#localCache[key] = value; // Store the string value directly
this.#localCacheKeyTTLMap[key] = Date.now() + (expiry > this.#localCacheTTL ? this.#localCacheTTL : expiry)*1000; // Store the expiry time
}
#getLocalValue(key) {
if (this.#localCacheKeyTTLMap[key] && this.#localCacheKeyTTLMap[key] < Date.now()) {
delete this.#localCache[key]; // Remove expired key
delete this.#localCacheKeyTTLMap[key]; // Remove key age tracking
return null; // Key expired
}
//we are not checking for quilify prefix as this should be taken care of when setting value in local cache
return this.#localCache[key]; // Return the cached value
}
#deleteLocalValue(key) {
delete this.#localCache[key]; // Remove expired key
delete this.#localCacheKeyTTLMap[key]; // Remove key age tracking
}
// --- Caching Methods ---
/**
* Retrieves a value from Redis using the provided key.
* Checks the local cache first, then Redis.
* Caches the value locally if it matches a prefix.
*
* @param {string} key The key to retrieve from Redis.
* @returns {Promise<{data: string | null, cacheHits: number, cacheMisses: number}>} Object containing the value and cache hit statistics.
* @throws {Error} If input is invalid or Redis command fails.
*/
async getCached(key) {
if (typeof key !== 'string') {
throw new Error("Input 'key' must be a string.");
}
if (!this.#redisClient || this.#redisClient.status !== 'ready') {
throw new Error("Redis client is not connected or ready.");
}
const value = this.#getLocalValue(key); // Check local cache first
if (value) {
return {data: value, cacheHits: 1, cacheMisses: 0}; // Return the cached value
}
// If not in local cache, fetch from Redis
try {
const value = await this.#redisClient.get(key);
this.#setLocalValue(key, value); // Update local cache
// Only cache locally if the value is not null AND it matches a prefix
if (value !== null && this.#qualifiesPrefix(key)) {
this.#localCache[key] = value; // Store the string value directly
this.#localCacheKeyTTLMap[key] = Date.now() + this.#localCacheTTL*1000; // Store the creation time
}
return {data: value, cacheHits: 0, cacheMisses: 1}; // Return the value from Redis
} catch (err) {
console.error("RedisClientSideCache: Error fetching key from Redis in getCached:", err);
throw new Error(`Failed to fetch key from Redis: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Sets a key-value pair in Redis and updates the local cache for keys matching the configured prefixes.
* Optionally sets expiry using `EXPIRE`.
*
* @param {string} key The key to set in Redis.
* @param {string | number | Buffer} value The value to set. Will be coerced to a string.
* @param {number} [expiry] Optional expiry time in seconds. If not provided or invalid, uses the instance's default expiry. If 0 or negative, expiry is not set.
* @returns {Promise<string>} Result from Redis SET command (ioredis usually returns 'OK').
* @throws {Error} If input is invalid or Redis command fails.
* */
async setCached(key, value, expiry = undefined) {
if (typeof key !== 'string') {
throw new Error("Input 'key' must be a string.");
}
if (!this.#redisClient || this.#redisClient.status !== 'ready') {
throw new Error("Redis client is not connected or ready.");
}
// Ensure value is suitable for Redis (string/buffer/number)
if (value === null || value === undefined) {
throw new Error("Input 'value' must be a non-null, non-undefined string, buffer, or number.");
}
const stringValue = String(value); // Coerce to string for consistency
try {
// Set expiry if specified
const expireSecs = (typeof expiry === 'number' && expiry >= 0) ? expiry : this.#defaultExpiry;
let ret = null;
// Set the value in Redis
if (expireSecs > 0) {
// Use SETEX for setting value with expiry
ret = await this.#redisClient.set(key, stringValue, 'EX', expireSecs);
} else {
// Use SET for setting value without expiry
ret = await this.#redisClient.set(key, stringValue);
}
this.#setLocalValue(key, stringValue, expireSecs); // Update local cache
return ret;
} catch (err) {
console.error("RedisClientSideCache: Error setting key in Redis in setCached:", err);
throw new Error(`Failed to set key in Redis: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Retrieves multiple keys, utilizing the local cache first.
* Fetches missing keys from Redis using `MGET`.
* Populates the local cache with fetched keys that match configured prefixes.
*
* @param {string[]} keys Array of keys to retrieve.
* @returns {Promise<{ data: Record<string, string | null>, cacheHits: number, cacheMisses: number }>}
* An object containing:
* - `data`: A key-value map of the results (value is `null` if key doesn't exist).
* - `cacheHits`: Number of keys served directly from the local cache.
* - `cacheMisses`: Number of keys that had to be fetched from Redis.
* @throws {Error} If input is invalid or Redis fetch fails.
*/
async mGetCached(keys) {
if (!Array.isArray(keys)) {
throw new Error("Input 'keys' must be an array of strings.");
}
if (!this.#redisClient || this.#redisClient.status !== 'ready') {
throw new Error("Redis client is not connected or ready.");
}
const results = {};
const keysToFetchFromRedis = [];
let localHits = 0;
// 1. Check local cache first
for (const key of keys) {
if (typeof key !== 'string') {
console.warn(`RedisClientSideCache: Non-string key encountered in mGetCached: ${key}. Skipping.`);
results[key] = null; // Or handle as an error depending on desired strictness
continue;
}
// Check if the key qualifies AND exists in the local cache
const val = this.#getLocalValue(key); // Check local cache first
if (val) {
results[key] = val;
localHits++;
} else {
keysToFetchFromRedis.push(key);
results[key] = null;
}
}
const misses = keysToFetchFromRedis.length;
// 2. Fetch missing keys from Redis
if (misses > 0) {
try {
// ioredis client.mget returns (string | null)[] in the same order as input keys
const redisValues = await this.#redisClient.mget(keysToFetchFromRedis);
// 3. Process Redis results and update local cache
for (let i = 0; i < keysToFetchFromRedis.length; i++) {
const key = keysToFetchFromRedis[i];
const value = redisValues[i]; // value can be string or null
results[key] = value; // Update the result map
this.#setLocalValue(key, value); // Update local cache
}
} catch (err) {
console.error("RedisClientSideCache: Error fetching keys from Redis in mGetCached:", err);
// How to handle partial failures? Current approach throws, losing all results.
// Alternative: Return partial results with an error indicator.
throw new Error(`Failed to fetch keys from Redis: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { data: results, cacheHits: localHits, cacheMisses: misses };
}
/**
* Sets multiple key-value pairs in Redis using `MSET` and updates the local cache
* for keys matching the configured prefixes. Optionally sets expiry using `EXPIRE`.
*
* @param {Record<string, string | number | Buffer>} data An object map of key-value pairs to set. Values will be coerced to strings.
* @param {number} [expiry] Optional expiry time in seconds. If not provided or invalid, uses the instance's default expiry. If 0 or negative, expiry is not set.
* @returns {Promise<string>} Result from Redis MSET command (ioredis usually returns 'OK').
* @throws {Error} If input is invalid or Redis command fails.
*/
async mSetCached(data, expiry) {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
throw new Error("Invalid data argument. Must be an object map of key-value pairs.");
}
if (!this.#redisClient || this.#redisClient.status !== 'ready') {
throw new Error("Redis client is not connected or ready.");
}
const keys = Object.keys(data);
if (keys.length === 0) {
return 'OK'; // Nothing to set
}
const msetData = {}; // Prepare data for ioredis mset
// 1. Update local cache and prepare data for Redis MSET
for (const key of keys) {
const value = data[key];
// Ensure value is suitable for Redis (string/buffer/number)
if (value === null || value === undefined) {
console.warn(`RedisClientSideCache: Skipping key "${key}" in mSetCached due to null/undefined value.`);
continue; // Skip this key-value pair
}
const stringValue = String(value); // Coerce to string for consistency
msetData[key] = stringValue; // Add to object for MSET
}
if (Object.keys(msetData).length === 0) {
_log("RedisClientSideCache: No valid key-value pairs remaining after filtering in mSetCached.");
return 'OK';
}
try {
// 2. Execute MSET command
// ioredis client.mset(object) returns 'OK' on success
const setResult = await this.#redisClient.mset(msetData);
// 3. Set expiry if specified, 0 expiry means no expiry
const expireSecs = (typeof expiry === 'number' && expiry >= 0) ? expiry : this.#defaultExpiry;
// Store new values in local cache
for (const [key, value] of Object.entries(msetData)) {
this.#setLocalValue(key, value, expireSecs);
}
if (typeof expireSecs === 'number' && expireSecs > 0) {
// Use MULTI/EXEC pipeline for setting multiple expiries efficiently
const multi = this.#redisClient.multi();
const keysToSetExpiry = Object.keys(msetData); // Get keys that were actually set
keysToSetExpiry.forEach(k => multi.expire(k, expireSecs));
const expiryResults = await multi.exec(); // Returns array of results like [[null, 1], [null, 0], ...]
// Optional: Check expiry results for errors (each element in expiryResults is [error, result])
expiryResults.forEach(([err, res], index) => {
if (err) {
console.warn(`RedisClientSideCache: Error setting expiry for key "${keysToSetExpiry[index]}":`, err);
}
// res should be 1 if expiry was set, 0 if key didn't exist (shouldn't happen right after MSET)
});
}
return setResult; // Return the result of MSET ('OK')
} catch (err) {
console.error("RedisClientSideCache: Error setting keys/expiry in Redis mSetCached:", err);
// Important: If MSET fails, the local cache might be inconsistent.
// Strategy: Attempt to remove the keys we *tried* to set from local cache on error?
for (const key of Object.keys(msetData)) {
if (this.#qualifiesPrefix(key)) {
delete this.#localCache[key]; // Attempt rollback on error
}
}
throw new Error(`Failed to set keys/expiry in Redis: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Deletes one or more keys from Redis using `DEL` and removes them from the local cache.
*
* @param {string | string[]} keys A single key or an array of keys to delete.
* @returns {Promise<number>} The number of keys successfully deleted from Redis.
* @throws {Error} If input is invalid or Redis command fails.
*/
async delCached(keys) {
const keysToDelete = Array.isArray(keys) ? keys : [keys]; // Ensure it's an array
if (!keysToDelete.every(k => typeof k === 'string')) {
throw new Error("Invalid keys argument. Must be a string or an array of strings.");
}
if (keysToDelete.length === 0) {
return 0; // Nothing to delete
}
if (!this.#redisClient || this.#redisClient.status !== 'ready') {
throw new Error("Redis client is not connected or ready.");
}
// 1. Remove keys from local cache immediately
let locallyRemovedCount = 0;
for (const key of keysToDelete) {
// Check prefix qualification? No, delete regardless of prefix if it exists locally.
// The user explicitly asked to delete this key.
if (Object.prototype.hasOwnProperty.call(this.#localCache, key)) {
delete this.#localCache[key];
delete this.#localCacheKeyTTLMap[key]; // Remove key age tracking
locallyRemovedCount++;
}
}
// _log(`RedisClientSideCache: Removed ${locallyRemovedCount} keys locally.`);
try {
// 2. Delete keys from Redis
// ioredis client.del([...keys]) returns the number of keys deleted
const redisDeleteCount = await this.#redisClient.del(keysToDelete);
return redisDeleteCount;
} catch (err) {
console.error("RedisClientSideCache: Error deleting keys from Redis in delCached:", err);
// Local cache is already updated. Should we try to revert? Difficult.
// Best to report the error and potentially have inconsistent state.
throw new Error(`Failed to delete keys from Redis: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Clears the entire local cache for all tracked prefixes.
* Does NOT affect Redis data.
*/
clearLocalCache() {
this.#localCache = {};
this.#localCacheKeyTTLMap = {}; // Clear the local cache
}
/**
* @private
* Disconnects the internally managed subscriber client and removes listeners.
* Should be called by the public `disconnect` method or during error cleanup in `create`.
* @param {boolean} [calledFromError=false] - Internal flag for logging/behavior adjustments.
* @returns {Promise<void>}
*/
async disconnectInternalResources(calledFromError = false) {
_log(`RedisClientSideCache: Disconnecting internal resources... ${calledFromError ? '(Called from error path)' : ''}`);
// Check if the main client is likely still connected enough to send a command
// You might adjust this check based on how you manage the main client's lifecycle
if (this.#redisClient.status === 'ready' || this.#redisClient.status === 'connect') {
_log("RedisClientSideCache: Attempting to turn off CLIENT TRACKING on main client...");
try {
const offResult = await this.#redisClient.call('CLIENT', 'TRACKING', 'OFF');
_log(`RedisClientSideCache: CLIENT TRACKING OFF result: ${offResult}`); // Should be 'OK'
if (offResult !== 'OK') {
// Log a warning if turning off tracking failed, but proceed with cleanup
console.warn(`RedisClientSideCache: CLIENT TRACKING OFF command did not return 'OK'. Result: ${offResult}`);
}
} catch (trackOffErr) {
console.warn("RedisClientSideCache: Error sending CLIENT TRACKING OFF to main client:", trackOffErr);
// Continue cleanup even if turning off tracking fails
}
} else {
console.warn(`RedisClientSideCache: Skipping CLIENT TRACKING OFF as main client status is '${this.#redisClient.status}'. Tracking might remain active on Redis server.`);
}
// 1. Remove message listener
if (this.#messageListener && this.#subscriber && typeof this.#subscriber.off === 'function') {
try {
this.#subscriber.off('message', this.#messageListener);
_log("RedisClientSideCache: Removed message listener.");
} catch (offErr) {
console.warn("RedisClientSideCache: Error removing message listener during disconnect:", offErr);
}
this.#messageListener = null; // Clear reference
}
// 2. Unsubscribe the subscriber client (best effort)
// Check status before unsubscribing if not called from error path, otherwise try anyway
const shouldAttemptUnsubscribe = this.#clientCachingTriggered && this.#subscriber && typeof this.#subscriber.unsubscribe === 'function';
const isSubConnected = this.#subscriber?.status === 'ready' || this.#subscriber?.status === 'connect';
if (shouldAttemptUnsubscribe && (calledFromError || isSubConnected)) {
try {
_log("RedisClientSideCache: Attempting to unsubscribe subscriber client...");
await this.#subscriber.unsubscribe("__redis__:invalidate");
_log("RedisClientSideCache: Unsubscribed from channel.");
} catch (err) {
// Log error but continue disconnect process
console.warn("RedisClientSideCache: Error during unsubscribe:", err.message || err);
}
} else if (shouldAttemptUnsubscribe) {
_log(`RedisClientSideCache: Skipping unsubscribe attempt. Status: ${this.#subscriber?.status}`);
}
// 3. Disconnect the subscriber client
if (this.#subscriber && typeof this.#subscriber.quit === 'function') {
_log("RedisClientSideCache: Quitting subscriber client...");
try {
// Don't wait indefinitely if called from error, but still initiate quit
const quitPromise = this.#subscriber.quit();
if (!calledFromError) {
await quitPromise; // Wait for graceful shutdown if possible
_log("RedisClientSideCache: Subscriber client quit successfully.");
} else {
quitPromise.catch(err => console.warn("RedisClientSideCache: Error during subscriber quit (in error path):", err));
_log("RedisClientSideCache: Initiated subscriber quit during error cleanup (no await).");
}
} catch (err) {
console.warn("RedisClientSideCache: Error quitting subscriber client:", err);
// If quit fails, try disconnect for a forceful close
if (typeof this.#subscriber.disconnect === 'function') {
_log("RedisClientSideCache: Attempting forceful disconnect of subscriber...");
this.#subscriber.disconnect();
}
}
}
// 4. Reset state
this.#subscriber = null; // Release reference
this.#clientCachingTriggered = false;
this.#localCache = {}; // Clear local cache on disconnect
_log("RedisClientSideCache: Internal resources disconnected and state reset.");
}
/**
* Gracefully disconnects the internal subscriber ioredis client using `quit()`.
* Removes the message listener and unsubscribes.
* **IMPORTANT:** This method **does not** disconnect the main `redisClient` passed during creation,
* as its lifecycle is managed externally. The caller is responsible for managing the main client.
*
* @returns {Promise<void>}
*/
async disconnect() {
_log("RedisClientSideCache: Disconnecting internal subscriber client and cleaning up...");
await this.disconnectInternalResources(false); // Call internal disconnect logic
// No need to touch this.#redisClient here
_log("RedisClientSideCache: Disconnect complete. Remember to manage the main client separately.");
}
/**
* Gets statistics about the local in-memory cache.
* @returns {{ size: number, keys: string[] }} Object containing the number of keys and an array of the keys currently held in the local cache.
*/
getLocalCacheStats() {
const keys = Object.keys(this.#localCache);
return { size: keys.length, keys: keys };
}
/**
* Retrieves the current value for a single key directly from the local cache, if it exists.
* Does not check Redis. Returns undefined if the key is not in the local cache.
* @param {string} key The key to retrieve from the local cache.
* @returns {string | undefined} The cached value, or undefined if not found locally.
*/
getLocalValue(key) {
return this.#getLocalValue(key); // Ensure the key is in the local cache
}
clearLocalCache() {
this.#localCache = {}; // Clear the local cache
this.#localCacheKeyTTLMap = {}; // Clear the local cache key TTL map
}
}
// Export the class using CommonJS syntax
module.exports = { RedisClientSideCache, _waitUntilReady};