diff --git a/.env.example b/.env.example index 396dcdf21c..e375c60a89 100644 --- a/.env.example +++ b/.env.example @@ -627,6 +627,10 @@ HELP_AND_FAQ_URL=https://librechat.ai # Redis connection limits # REDIS_MAX_LISTENERS=40 +# Force specific cache namespaces to use in-memory storage even when Redis is enabled +# Comma-separated list of CacheKeys (e.g., STATIC_CONFIG,ROLES,MESSAGES) +# FORCED_IN_MEMORY_CACHE_NAMESPACES=STATIC_CONFIG,ROLES + #==================================================# # Others # #==================================================# diff --git a/api/cache/cacheConfig.js b/api/cache/cacheConfig.js index 534b3f4b35..551107f7a4 100644 --- a/api/cache/cacheConfig.js +++ b/api/cache/cacheConfig.js @@ -1,5 +1,6 @@ const fs = require('fs'); const { math, isEnabled } = require('@librechat/api'); +const { CacheKeys } = require('librechat-data-provider'); // To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. // This prefix is usually the deployment ID, which is often passed to the container or pod as an env var. @@ -15,7 +16,26 @@ if (USE_REDIS && !process.env.REDIS_URI) { throw new Error('USE_REDIS is enabled but REDIS_URI is not set.'); } +// Comma-separated list of cache namespaces that should be forced to use in-memory storage +// even when Redis is enabled. This allows selective performance optimization for specific caches. +const FORCED_IN_MEMORY_CACHE_NAMESPACES = process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES + ? process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES.split(',').map((key) => key.trim()) + : []; + +// Validate against CacheKeys enum +if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { + const validKeys = Object.values(CacheKeys); + const invalidKeys = FORCED_IN_MEMORY_CACHE_NAMESPACES.filter((key) => !validKeys.includes(key)); + + if (invalidKeys.length > 0) { + throw new Error( + `Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: ${invalidKeys.join(', ')}. Valid keys: ${validKeys.join(', ')}`, + ); + } +} + const cacheConfig = { + FORCED_IN_MEMORY_CACHE_NAMESPACES, USE_REDIS, REDIS_URI: process.env.REDIS_URI, REDIS_USERNAME: process.env.REDIS_USERNAME, diff --git a/api/cache/cacheConfig.spec.js b/api/cache/cacheConfig.spec.js index d931bf0ce1..0809d6f62e 100644 --- a/api/cache/cacheConfig.spec.js +++ b/api/cache/cacheConfig.spec.js @@ -14,6 +14,7 @@ describe('cacheConfig', () => { delete process.env.REDIS_KEY_PREFIX_VAR; delete process.env.REDIS_KEY_PREFIX; delete process.env.USE_REDIS; + delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; // Clear require cache jest.resetModules(); @@ -105,4 +106,37 @@ describe('cacheConfig', () => { expect(cacheConfig.REDIS_CA).toBeNull(); }); }); + + describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => { + test('should parse comma-separated cache keys correctly', () => { + process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, STATIC_CONFIG ,MESSAGES '; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([ + 'ROLES', + 'STATIC_CONFIG', + 'MESSAGES', + ]); + }); + + test('should throw error for invalid cache keys', () => { + process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'INVALID_KEY,ROLES'; + + expect(() => { + require('./cacheConfig'); + }).toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY'); + }); + + test('should handle empty string gracefully', () => { + process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ''; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]); + }); + + test('should handle undefined env var gracefully', () => { + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]); + }); + }); }); diff --git a/api/cache/cacheFactory.js b/api/cache/cacheFactory.js index b4cbd2ef46..b9739f4d39 100644 --- a/api/cache/cacheFactory.js +++ b/api/cache/cacheFactory.js @@ -16,7 +16,10 @@ const { RedisStore } = require('rate-limit-redis'); * @returns {Keyv} Cache instance. */ const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => { - if (cacheConfig.USE_REDIS) { + if ( + cacheConfig.USE_REDIS && + !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace) + ) { const keyvRedis = new KeyvRedis(keyvRedisClient); const cache = new Keyv(keyvRedis, { namespace, ttl }); keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; diff --git a/api/cache/cacheFactory.spec.js b/api/cache/cacheFactory.spec.js index 4f9840b811..76d01a915c 100644 --- a/api/cache/cacheFactory.spec.js +++ b/api/cache/cacheFactory.spec.js @@ -31,6 +31,7 @@ jest.mock('./cacheConfig', () => ({ cacheConfig: { USE_REDIS: false, REDIS_KEY_PREFIX: 'test', + FORCED_IN_MEMORY_CACHE_NAMESPACES: [], }, })); @@ -63,6 +64,7 @@ describe('cacheFactory', () => { // Reset cache config mock cacheConfig.USE_REDIS = false; cacheConfig.REDIS_KEY_PREFIX = 'test'; + cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = []; }); describe('redisCache', () => { @@ -116,6 +118,30 @@ describe('cacheFactory', () => { expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined }); }); + + it('should use fallback when namespace is in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => { + cacheConfig.USE_REDIS = true; + cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['forced-memory']; + const namespace = 'forced-memory'; + const ttl = 3600; + + standardCache(namespace, ttl); + + expect(require('@keyv/redis').default).not.toHaveBeenCalled(); + expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); + }); + + it('should use Redis when namespace is not in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => { + cacheConfig.USE_REDIS = true; + cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['other-namespace']; + const namespace = 'test-namespace'; + const ttl = 3600; + + standardCache(namespace, ttl); + + expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); + expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); + }); }); describe('violationCache', () => { diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index aca53fcfcc..675e16a76a 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -33,6 +33,7 @@ const namespaces = { [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES), [CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS), [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE), + [CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG), [CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ), [CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }), [CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES), diff --git a/api/server/services/Config/getCustomConfig.js b/api/server/services/Config/getCustomConfig.js index a7cb74de54..2b9f658b48 100644 --- a/api/server/services/Config/getCustomConfig.js +++ b/api/server/services/Config/getCustomConfig.js @@ -11,8 +11,8 @@ const getLogStores = require('~/cache/getLogStores'); * @returns {Promise} * */ async function getCustomConfig() { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig()); + const cache = getLogStores(CacheKeys.STATIC_CONFIG); + return (await cache.get(CacheKeys.LIBRECHAT_YAML_CONFIG)) || (await loadCustomConfig()); } /** diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 2ff7d5373f..efa5e86548 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -120,8 +120,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`); .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); if (customConfig.cache) { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); + const cache = getLogStores(CacheKeys.STATIC_CONFIG); + await cache.set(CacheKeys.LIBRECHAT_YAML_CONFIG, customConfig); } if (result.data.modelSpecs) { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a1510d99a1..89a3aa1383 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1122,85 +1122,88 @@ export enum CacheKeys { /** * Key for the config store namespace. */ - CONFIG_STORE = 'configStore', + CONFIG_STORE = 'CONFIG_STORE', /** - * Key for the config store namespace. + * Key for the roles cache. */ - ROLES = 'roles', + ROLES = 'ROLES', /** * Key for the plugins cache. */ - PLUGINS = 'plugins', + PLUGINS = 'PLUGINS', /** * Key for the title generation cache. */ - GEN_TITLE = 'genTitle', - /** + GEN_TITLE = 'GEN_TITLE', /** * Key for the tools cache. */ - TOOLS = 'tools', + TOOLS = 'TOOLS', /** * Key for the model config cache. */ - MODELS_CONFIG = 'modelsConfig', + MODELS_CONFIG = 'MODELS_CONFIG', /** * Key for the model queries cache. */ - MODEL_QUERIES = 'modelQueries', + MODEL_QUERIES = 'MODEL_QUERIES', /** * Key for the default startup config cache. */ - STARTUP_CONFIG = 'startupConfig', + STARTUP_CONFIG = 'STARTUP_CONFIG', /** * Key for the default endpoint config cache. */ - ENDPOINT_CONFIG = 'endpointsConfig', + ENDPOINT_CONFIG = 'ENDPOINT_CONFIG', /** * Key for accessing the model token config cache. */ - TOKEN_CONFIG = 'tokenConfig', + TOKEN_CONFIG = 'TOKEN_CONFIG', /** - * Key for the custom config cache. + * Key for the librechat yaml config cache. */ - CUSTOM_CONFIG = 'customConfig', + LIBRECHAT_YAML_CONFIG = 'LIBRECHAT_YAML_CONFIG', + /** + * Key for the static config namespace. + */ + STATIC_CONFIG = 'STATIC_CONFIG', /** * Key for accessing Abort Keys */ - ABORT_KEYS = 'abortKeys', + ABORT_KEYS = 'ABORT_KEYS', /** * Key for the override config cache. */ - OVERRIDE_CONFIG = 'overrideConfig', + OVERRIDE_CONFIG = 'OVERRIDE_CONFIG', /** * Key for the bans cache. */ - BANS = 'bans', + BANS = 'BANS', /** * Key for the encoded domains cache. * Used by Azure OpenAI Assistants. */ - ENCODED_DOMAINS = 'encoded_domains', + ENCODED_DOMAINS = 'ENCODED_DOMAINS', /** * Key for the cached audio run Ids. */ - AUDIO_RUNS = 'audioRuns', + AUDIO_RUNS = 'AUDIO_RUNS', /** * Key for in-progress messages. */ - MESSAGES = 'messages', + MESSAGES = 'MESSAGES', /** * Key for in-progress flow states. */ - FLOWS = 'flows', + FLOWS = 'FLOWS', /** * Key for individual MCP Tool Manifests. */ - MCP_TOOLS = 'mcp_tools', + MCP_TOOLS = 'MCP_TOOLS', /** * Key for pending chat requests (concurrency check) */ - PENDING_REQ = 'pending_req', + PENDING_REQ = 'PENDING_REQ', /** * Key for s3 check intervals per user */ @@ -1212,11 +1215,11 @@ export enum CacheKeys { /** * Key for OpenID session. */ - OPENID_SESSION = 'openid_session', + OPENID_SESSION = 'OPENID_SESSION', /** * Key for SAML session. */ - SAML_SESSION = 'saml_session', + SAML_SESSION = 'SAML_SESSION', } /**