LibreChat/packages/api/src/cache/cacheFactory.ts
Atef Bellaaj ac68e629e6
📡 refactor: MCP Runtime Config Sync with Redis Distributed Locking (#10352)
* 🔄 Refactoring: MCP Runtime Configuration Reload
 - PrivateServerConfigs own cache classes (inMemory and Redis).
 - Connections staleness detection by comparing (connection.createdAt and config.LastUpdatedAt)
 - ConnectionsRepo access Registry instead of in memory config dict and renew stale connections
 - MCPManager: adjusted init of ConnectionsRepo (app level)
 - UserConnectionManager: renew stale connections
 - skipped test, to test "should only clear keys in its own namespace"
 - MCPPrivateServerLoader: new component to manage logic of loading / editing private servers on runtime
 - PrivateServersLoadStatusCache to track private server cache status
 - New unit and integration tests.
Misc:
 - add es lint rule to enforce line between class methods

* Fix cluster mode batch update and delete workarround. Fixed unit tests for cluster mode.

* Fix Keyv redis clear cache namespace  awareness issue + Integration tests fixes

* chore: address copilot comments

* Fixing rebase issue: removed the mcp config fallback in single getServerConfig method:
- to not to interfere with the logic of the right Tier (APP/USER/Private)
- If userId is null, the getServerConfig should not return configs that are a SharedUser tier and not APP tier

* chore: add dev-staging branch to workflow triggers for backend, cache integration, and ESLint checks

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-12-11 16:36:15 -05:00

143 lines
5.5 KiB
TypeScript

/**
* @keyv/redis exports its default class in a non-standard way:
* module.exports = { default: KeyvRedis, ... } instead of module.exports = KeyvRedis
* This breaks ES6 imports when the module is marked as external in rollup.
* We must use require() to access the .default property directly.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const KeyvRedis = require('@keyv/redis').default as typeof import('@keyv/redis').default;
import { Keyv } from 'keyv';
import createMemoryStore from 'memorystore';
import { RedisStore } from 'rate-limit-redis';
import { Time } from 'librechat-data-provider';
import { logger } from '@librechat/data-schemas';
import session, { MemoryStore } from 'express-session';
import { RedisStore as ConnectRedis } from 'connect-redis';
import type { SendCommandFn } from 'rate-limit-redis';
import { keyvRedisClient, ioredisClient } from './redisClients';
import { cacheConfig } from './cacheConfig';
import { violationFile } from './keyvFiles';
import { batchDeleteKeys, scanKeys } from './redisUtils';
/**
* Creates a cache instance using Redis or a fallback store. Suitable for general caching needs.
* @param namespace - The cache namespace.
* @param ttl - Time to live for cache entries.
* @param fallbackStore - Optional fallback store if Redis is not used.
* @returns Cache instance.
*/
export const standardCache = (namespace: string, ttl?: number, fallbackStore?: object): Keyv => {
if (keyvRedisClient && !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)) {
try {
const keyvRedis = new KeyvRedis(keyvRedisClient);
const cache = new Keyv(keyvRedis, { namespace, ttl });
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
keyvRedis.keyPrefixSeparator = cacheConfig.GLOBAL_PREFIX_SEPARATOR;
cache.on('error', (err) => {
logger.error(`Cache error in namespace ${namespace}:`, err);
});
// Override clear() to handle namespace-aware deletion
// The default Keyv clear() doesn't respect namespace due to the workaround above
// Workaround for issue #10487 https://github.com/danny-avila/LibreChat/issues/10487
cache.clear = async () => {
// Type-safe check for Redis client with scanIterator support
if (!keyvRedisClient || !('scanIterator' in keyvRedisClient)) {
logger.warn(`Cannot clear namespace ${namespace}: Redis scanIterator not available`);
return;
}
// Build pattern: globalPrefix::namespace:* or namespace:*
const pattern = cacheConfig.REDIS_KEY_PREFIX
? `${cacheConfig.REDIS_KEY_PREFIX}${cacheConfig.GLOBAL_PREFIX_SEPARATOR}${namespace}:*`
: `${namespace}:*`;
// Use utility functions for efficient scan and parallel deletion
const keysToDelete = await scanKeys(keyvRedisClient, pattern);
if (keysToDelete.length === 0) {
return;
}
await batchDeleteKeys(keyvRedisClient, keysToDelete);
logger.debug(`Cleared ${keysToDelete.length} keys from namespace ${namespace}`);
};
return cache;
} catch (err) {
logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err);
throw err;
}
}
if (fallbackStore) {
return new Keyv({ store: fallbackStore, namespace, ttl });
}
return new Keyv({ namespace, ttl });
};
/**
* Creates a cache instance for storing violation data.
* Uses a file-based fallback store if Redis is not enabled.
* @param namespace - The cache namespace for violations.
* @param ttl - Time to live for cache entries.
* @returns Cache instance for violations.
*/
export const violationCache = (namespace: string, ttl?: number): Keyv => {
return standardCache(`violations:${namespace}`, ttl, violationFile);
};
/**
* Creates a session cache instance using Redis or in-memory store.
* @param namespace - The session namespace.
* @param ttl - Time to live for session entries.
* @returns Session store instance.
*/
export const sessionCache = (namespace: string, ttl?: number): MemoryStore | ConnectRedis => {
namespace = namespace.endsWith(':') ? namespace : `${namespace}:`;
if (!cacheConfig.USE_REDIS) {
const MemoryStore = createMemoryStore(session);
return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY });
}
const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
if (ioredisClient) {
ioredisClient.on('error', (err) => {
logger.error(`Session store Redis error for namespace ${namespace}:`, err);
});
}
return store;
};
/**
* Creates a rate limiter cache using Redis.
* @param prefix - The key prefix for rate limiting.
* @returns RedisStore instance or undefined if Redis is not used.
*/
export const limiterCache = (prefix: string): RedisStore | undefined => {
if (!prefix) {
throw new Error('prefix is required');
}
if (!cacheConfig.USE_REDIS) {
return undefined;
}
// TODO: The prefix is not actually applied. Also needs to account for global prefix.
prefix = prefix.endsWith(':') ? prefix : `${prefix}:`;
try {
const sendCommand: SendCommandFn = (async (...args: string[]) => {
if (ioredisClient == null) {
throw new Error('Redis client not available');
}
try {
return await ioredisClient.call(args[0], ...args.slice(1));
} catch (err) {
logger.error('Redis command execution failed:', err);
throw err;
}
}) as SendCommandFn;
return new RedisStore({ sendCommand, prefix });
} catch (err) {
logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err);
return undefined;
}
};