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_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 #
#==================================================#

View file

@ -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,

View file

@ -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([]);
});
});
});

View file

@ -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;

View file

@ -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', () => {

View file

@ -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),

View file

@ -11,8 +11,8 @@ const getLogStores = require('~/cache/getLogStores');
* @returns {Promise<TCustomConfig | null>}
* */
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());
}
/**

View file

@ -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) {

View file

@ -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',
}
/**