feat: Add support for forced in-memory cache namespaces configuration (#8586)

*  feat: Add support for forced in-memory cache keys configuration

* refactor: Update cache keys to use uppercase constants and moved cache for `librechat.yaml` into its own cache namespace (STATIC_CONFIG) and with a more descriptive key (LIBRECHAT_YAML_CONFIG)
This commit is contained in:
Theo N. Truong 2025-07-25 08:23:36 -06:00 committed by Danny Avila
parent 3dc9e85fab
commit 21005b66cc
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
9 changed files with 121 additions and 30 deletions

View file

@ -627,6 +627,10 @@ HELP_AND_FAQ_URL=https://librechat.ai
# Redis connection limits # Redis connection limits
# REDIS_MAX_LISTENERS=40 # 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 # # Others #
#==================================================# #==================================================#

View file

@ -1,5 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const { math, isEnabled } = require('@librechat/api'); 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. // 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. // 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.'); 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 = { const cacheConfig = {
FORCED_IN_MEMORY_CACHE_NAMESPACES,
USE_REDIS, USE_REDIS,
REDIS_URI: process.env.REDIS_URI, REDIS_URI: process.env.REDIS_URI,
REDIS_USERNAME: process.env.REDIS_USERNAME, REDIS_USERNAME: process.env.REDIS_USERNAME,

View file

@ -14,6 +14,7 @@ describe('cacheConfig', () => {
delete process.env.REDIS_KEY_PREFIX_VAR; delete process.env.REDIS_KEY_PREFIX_VAR;
delete process.env.REDIS_KEY_PREFIX; delete process.env.REDIS_KEY_PREFIX;
delete process.env.USE_REDIS; delete process.env.USE_REDIS;
delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES;
// Clear require cache // Clear require cache
jest.resetModules(); jest.resetModules();
@ -105,4 +106,37 @@ describe('cacheConfig', () => {
expect(cacheConfig.REDIS_CA).toBeNull(); 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([]);
});
});
}); });

View file

@ -16,7 +16,10 @@ const { RedisStore } = require('rate-limit-redis');
* @returns {Keyv} Cache instance. * @returns {Keyv} Cache instance.
*/ */
const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => { 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 keyvRedis = new KeyvRedis(keyvRedisClient);
const cache = new Keyv(keyvRedis, { namespace, ttl }); const cache = new Keyv(keyvRedis, { namespace, ttl });
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;

View file

@ -31,6 +31,7 @@ jest.mock('./cacheConfig', () => ({
cacheConfig: { cacheConfig: {
USE_REDIS: false, USE_REDIS: false,
REDIS_KEY_PREFIX: 'test', REDIS_KEY_PREFIX: 'test',
FORCED_IN_MEMORY_CACHE_NAMESPACES: [],
}, },
})); }));
@ -63,6 +64,7 @@ describe('cacheFactory', () => {
// Reset cache config mock // Reset cache config mock
cacheConfig.USE_REDIS = false; cacheConfig.USE_REDIS = false;
cacheConfig.REDIS_KEY_PREFIX = 'test'; cacheConfig.REDIS_KEY_PREFIX = 'test';
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = [];
}); });
describe('redisCache', () => { describe('redisCache', () => {
@ -116,6 +118,30 @@ describe('cacheFactory', () => {
expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined }); 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', () => { describe('violationCache', () => {

View file

@ -33,6 +33,7 @@ const namespaces = {
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES), [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS), [CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE), [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ), [CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
[CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }), [CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
[CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES), [CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),

View file

@ -11,8 +11,8 @@ const getLogStores = require('~/cache/getLogStores');
* @returns {Promise<TCustomConfig | null>} * @returns {Promise<TCustomConfig | null>}
* */ * */
async function getCustomConfig() { async function getCustomConfig() {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.STATIC_CONFIG);
return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig()); return (await cache.get(CacheKeys.LIBRECHAT_YAML_CONFIG)) || (await loadCustomConfig());
} }
/** /**

View file

@ -120,8 +120,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
if (customConfig.cache) { if (customConfig.cache) {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.STATIC_CONFIG);
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); await cache.set(CacheKeys.LIBRECHAT_YAML_CONFIG, customConfig);
} }
if (result.data.modelSpecs) { if (result.data.modelSpecs) {

View file

@ -1122,85 +1122,88 @@ export enum CacheKeys {
/** /**
* Key for the config store namespace. * 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. * Key for the plugins cache.
*/ */
PLUGINS = 'plugins', PLUGINS = 'PLUGINS',
/** /**
* Key for the title generation cache. * Key for the title generation cache.
*/ */
GEN_TITLE = 'genTitle', GEN_TITLE = 'GEN_TITLE',
/**
/** /**
* Key for the tools cache. * Key for the tools cache.
*/ */
TOOLS = 'tools', TOOLS = 'TOOLS',
/** /**
* Key for the model config cache. * Key for the model config cache.
*/ */
MODELS_CONFIG = 'modelsConfig', MODELS_CONFIG = 'MODELS_CONFIG',
/** /**
* Key for the model queries cache. * Key for the model queries cache.
*/ */
MODEL_QUERIES = 'modelQueries', MODEL_QUERIES = 'MODEL_QUERIES',
/** /**
* Key for the default startup config cache. * Key for the default startup config cache.
*/ */
STARTUP_CONFIG = 'startupConfig', STARTUP_CONFIG = 'STARTUP_CONFIG',
/** /**
* Key for the default endpoint config cache. * Key for the default endpoint config cache.
*/ */
ENDPOINT_CONFIG = 'endpointsConfig', ENDPOINT_CONFIG = 'ENDPOINT_CONFIG',
/** /**
* Key for accessing the model token config cache. * 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 * Key for accessing Abort Keys
*/ */
ABORT_KEYS = 'abortKeys', ABORT_KEYS = 'ABORT_KEYS',
/** /**
* Key for the override config cache. * Key for the override config cache.
*/ */
OVERRIDE_CONFIG = 'overrideConfig', OVERRIDE_CONFIG = 'OVERRIDE_CONFIG',
/** /**
* Key for the bans cache. * Key for the bans cache.
*/ */
BANS = 'bans', BANS = 'BANS',
/** /**
* Key for the encoded domains cache. * Key for the encoded domains cache.
* Used by Azure OpenAI Assistants. * Used by Azure OpenAI Assistants.
*/ */
ENCODED_DOMAINS = 'encoded_domains', ENCODED_DOMAINS = 'ENCODED_DOMAINS',
/** /**
* Key for the cached audio run Ids. * Key for the cached audio run Ids.
*/ */
AUDIO_RUNS = 'audioRuns', AUDIO_RUNS = 'AUDIO_RUNS',
/** /**
* Key for in-progress messages. * Key for in-progress messages.
*/ */
MESSAGES = 'messages', MESSAGES = 'MESSAGES',
/** /**
* Key for in-progress flow states. * Key for in-progress flow states.
*/ */
FLOWS = 'flows', FLOWS = 'FLOWS',
/** /**
* Key for individual MCP Tool Manifests. * Key for individual MCP Tool Manifests.
*/ */
MCP_TOOLS = 'mcp_tools', MCP_TOOLS = 'MCP_TOOLS',
/** /**
* Key for pending chat requests (concurrency check) * Key for pending chat requests (concurrency check)
*/ */
PENDING_REQ = 'pending_req', PENDING_REQ = 'PENDING_REQ',
/** /**
* Key for s3 check intervals per user * Key for s3 check intervals per user
*/ */
@ -1212,11 +1215,11 @@ export enum CacheKeys {
/** /**
* Key for OpenID session. * Key for OpenID session.
*/ */
OPENID_SESSION = 'openid_session', OPENID_SESSION = 'OPENID_SESSION',
/** /**
* Key for SAML session. * Key for SAML session.
*/ */
SAML_SESSION = 'saml_session', SAML_SESSION = 'SAML_SESSION',
} }
/** /**