🗃️ refactor: Separate Tool Cache Namespace for Blue/Green Deployments (#11738)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* 🔧 refactor: Introduce TOOL_CACHE for isolated caching of tools

- Added TOOL_CACHE key to CacheKeys enum for managing tool-related cache.
- Updated various services and controllers to utilize TOOL_CACHE instead of CONFIG_STORE for better separation of concerns in caching logic.
- Enhanced .env.example with comments on using in-memory cache for blue/green deployments.

* 🔧 refactor: Update cache configuration for in-memory storage handling

- Enhanced the handling of `FORCED_IN_MEMORY_CACHE_NAMESPACES` in `cacheConfig.ts` to default to `CONFIG_STORE` and `APP_CONFIG`, ensuring safer blue/green deployments.
- Updated `.env.example` with clearer comments regarding the usage of in-memory cache namespaces.
- Improved unit tests to validate the new default behavior and handling of empty strings for cache namespaces.
This commit is contained in:
Danny Avila 2026-02-11 22:20:43 -05:00 committed by GitHub
parent c7531dd029
commit 5b67e48fe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 284 additions and 18 deletions

View file

@ -748,8 +748,10 @@ HELP_AND_FAQ_URL=https://librechat.ai
# REDIS_PING_INTERVAL=300
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
# Comma-separated list of CacheKeys
# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments)
# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES=
# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG
# Leader Election Configuration (for multi-instance deployments with Redis)
# Duration in seconds that the leader lease is valid before it expires (default: 25)

View file

@ -37,6 +37,7 @@ const namespaces = {
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.TOOL_CACHE]: standardCache(CacheKeys.TOOL_CACHE),
[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

@ -8,7 +8,7 @@ const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
try {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const cachedPlugins = await cache.get(CacheKeys.PLUGINS);
if (cachedPlugins) {
res.status(200).json(cachedPlugins);
@ -63,7 +63,7 @@ const getAvailableTools = async (req, res) => {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));

View file

@ -1,3 +1,4 @@
const { CacheKeys } = require('librechat-data-provider');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
@ -63,6 +64,28 @@ describe('PluginController', () => {
});
});
describe('cache namespace', () => {
it('getAvailablePluginsController should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailablePluginsController(mockReq, mockRes);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('getAvailableTools should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailableTools(mockReq, mockRes);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('should NOT use CONFIG_STORE namespace for tool/plugin operations', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailablePluginsController(mockReq, mockRes);
await getAvailableTools(mockReq, mockRes);
const allCalls = getLogStores.mock.calls.flat();
expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
});
});
describe('getAvailablePluginsController', () => {
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
// Add plugins with duplicates to availableTools

View file

@ -1,10 +1,92 @@
const { ToolCacheKeys } = require('../getCachedTools');
const { CacheKeys } = require('librechat-data-provider');
jest.mock('~/cache/getLogStores');
const getLogStores = require('~/cache/getLogStores');
const mockCache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() };
getLogStores.mockReturnValue(mockCache);
const {
ToolCacheKeys,
getCachedTools,
setCachedTools,
getMCPServerTools,
invalidateCachedTools,
} = require('../getCachedTools');
describe('getCachedTools', () => {
beforeEach(() => {
jest.clearAllMocks();
getLogStores.mockReturnValue(mockCache);
});
describe('getCachedTools - Cache Isolation Security', () => {
describe('ToolCacheKeys.MCP_SERVER', () => {
it('should generate cache keys that include userId', () => {
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
expect(key).toBe('tools:mcp:user123:github');
});
});
describe('TOOL_CACHE namespace usage', () => {
it('getCachedTools should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue(null);
await getCachedTools();
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('getCachedTools with MCP server options should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue({ tool1: {} });
await getCachedTools({ userId: 'user1', serverName: 'github' });
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
});
it('setCachedTools should use TOOL_CACHE namespace', async () => {
mockCache.set.mockResolvedValue(true);
const tools = { tool1: { type: 'function' } };
await setCachedTools(tools);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.set).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL, tools, expect.any(Number));
});
it('setCachedTools with MCP server options should use TOOL_CACHE namespace', async () => {
mockCache.set.mockResolvedValue(true);
const tools = { tool1: { type: 'function' } };
await setCachedTools(tools, { userId: 'user1', serverName: 'github' });
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github'),
tools,
expect.any(Number),
);
});
it('invalidateCachedTools should use TOOL_CACHE namespace', async () => {
mockCache.delete.mockResolvedValue(true);
await invalidateCachedTools({ invalidateGlobal: true });
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
});
it('getMCPServerTools should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue(null);
await getMCPServerTools('user1', 'github');
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
});
it('should NOT use CONFIG_STORE namespace', async () => {
mockCache.get.mockResolvedValue(null);
await getCachedTools();
await getMCPServerTools('user1', 'github');
mockCache.set.mockResolvedValue(true);
await setCachedTools({ tool1: {} });
mockCache.delete.mockResolvedValue(true);
await invalidateCachedTools({ invalidateGlobal: true });
const allCalls = getLogStores.mock.calls.flat();
expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
expect(allCalls.every((key) => key === CacheKeys.TOOL_CACHE)).toBe(true);
});
});
});

View file

@ -20,7 +20,7 @@ const ToolCacheKeys = {
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName } = options;
// Return MCP server-specific tools if requested
@ -43,7 +43,7 @@ async function getCachedTools(options = {}) {
* @returns {Promise<boolean>} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName, ttl = Time.TWELVE_HOURS } = options;
// Cache by MCP server if specified (requires userId)
@ -65,7 +65,7 @@ async function setCachedTools(tools, options = {}) {
* @returns {Promise<void>}
*/
async function invalidateCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName, invalidateGlobal = false } = options;
const keysToDelete = [];
@ -89,7 +89,7 @@ async function invalidateCachedTools(options = {}) {
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
*/
async function getMCPServerTools(userId, serverName) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
if (serverTools) {

View file

@ -35,7 +35,7 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
await setCachedTools(serverTools, { userId, serverName });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
@ -61,7 +61,7 @@ async function mergeAppTools(appTools) {
const cachedTools = await getCachedTools();
const mergedTools = { ...cachedTools, ...appTools };
await setCachedTools(mergedTools);
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} app-level tools`);
} catch (error) {

View file

@ -215,16 +215,30 @@ describe('cacheConfig', () => {
}).rejects.toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY');
});
test('should handle empty string gracefully', async () => {
test('should produce empty array when set to empty string (opt out of defaults)', async () => {
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = '';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]);
});
test('should handle undefined env var gracefully', async () => {
test('should default to CONFIG_STORE and APP_CONFIG when env var is not set', async () => {
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]);
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['CONFIG_STORE', 'APP_CONFIG']);
});
test('should accept TOOL_CACHE as a valid namespace', async () => {
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'TOOL_CACHE';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['TOOL_CACHE']);
});
test('should accept CONFIG_STORE and APP_CONFIG together for blue/green deployments', async () => {
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'CONFIG_STORE,APP_CONFIG';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['CONFIG_STORE', 'APP_CONFIG']);
});
});
});

View file

@ -0,0 +1,135 @@
import { CacheKeys } from 'librechat-data-provider';
const mockKeyvRedisInstance = {
namespace: '',
keyPrefixSeparator: '',
on: jest.fn(),
};
const MockKeyvRedis = jest.fn().mockReturnValue(mockKeyvRedisInstance);
jest.mock('@keyv/redis', () => ({
default: MockKeyvRedis,
}));
const mockKeyvRedisClient = { scanIterator: jest.fn() };
jest.mock('../../redisClients', () => ({
keyvRedisClient: mockKeyvRedisClient,
ioredisClient: null,
}));
jest.mock('../../redisUtils', () => ({
batchDeleteKeys: jest.fn(),
scanKeys: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
describe('standardCache - CONFIG_STORE vs TOOL_CACHE namespace isolation', () => {
afterEach(() => {
jest.resetModules();
MockKeyvRedis.mockClear();
});
/**
* Core behavioral test for blue/green deployments:
* When CONFIG_STORE and APP_CONFIG are forced in-memory,
* TOOL_CACHE should still use Redis for cross-container sharing.
*/
it('should force CONFIG_STORE to in-memory while TOOL_CACHE uses Redis', async () => {
jest.doMock('../../cacheConfig', () => ({
cacheConfig: {
FORCED_IN_MEMORY_CACHE_NAMESPACES: [CacheKeys.CONFIG_STORE, CacheKeys.APP_CONFIG],
REDIS_KEY_PREFIX: '',
GLOBAL_PREFIX_SEPARATOR: '>>',
},
}));
const { standardCache } = await import('../../cacheFactory');
MockKeyvRedis.mockClear();
const configCache = standardCache(CacheKeys.CONFIG_STORE);
expect(MockKeyvRedis).not.toHaveBeenCalled();
expect(configCache).toBeDefined();
const appConfigCache = standardCache(CacheKeys.APP_CONFIG);
expect(MockKeyvRedis).not.toHaveBeenCalled();
expect(appConfigCache).toBeDefined();
const toolCache = standardCache(CacheKeys.TOOL_CACHE);
expect(MockKeyvRedis).toHaveBeenCalledTimes(1);
expect(MockKeyvRedis).toHaveBeenCalledWith(mockKeyvRedisClient);
expect(toolCache).toBeDefined();
});
it('CONFIG_STORE and TOOL_CACHE should be independent stores', async () => {
jest.doMock('../../cacheConfig', () => ({
cacheConfig: {
FORCED_IN_MEMORY_CACHE_NAMESPACES: [CacheKeys.CONFIG_STORE],
REDIS_KEY_PREFIX: '',
GLOBAL_PREFIX_SEPARATOR: '>>',
},
}));
const { standardCache } = await import('../../cacheFactory');
const configCache = standardCache(CacheKeys.CONFIG_STORE);
const toolCache = standardCache(CacheKeys.TOOL_CACHE);
await configCache.set('STARTUP_CONFIG', { version: 'v2-green' });
await toolCache.set('tools:global', { myTool: { type: 'function' } });
expect(await configCache.get('STARTUP_CONFIG')).toEqual({ version: 'v2-green' });
expect(await configCache.get('tools:global')).toBeUndefined();
expect(await toolCache.get('STARTUP_CONFIG')).toBeUndefined();
});
it('should use Redis for all namespaces when nothing is forced in-memory', async () => {
jest.doMock('../../cacheConfig', () => ({
cacheConfig: {
FORCED_IN_MEMORY_CACHE_NAMESPACES: [],
REDIS_KEY_PREFIX: '',
GLOBAL_PREFIX_SEPARATOR: '>>',
},
}));
const { standardCache } = await import('../../cacheFactory');
MockKeyvRedis.mockClear();
standardCache(CacheKeys.CONFIG_STORE);
standardCache(CacheKeys.TOOL_CACHE);
standardCache(CacheKeys.APP_CONFIG);
expect(MockKeyvRedis).toHaveBeenCalledTimes(3);
});
it('forcing TOOL_CACHE to in-memory should not affect CONFIG_STORE', async () => {
jest.doMock('../../cacheConfig', () => ({
cacheConfig: {
FORCED_IN_MEMORY_CACHE_NAMESPACES: [CacheKeys.TOOL_CACHE],
REDIS_KEY_PREFIX: '',
GLOBAL_PREFIX_SEPARATOR: '>>',
},
}));
const { standardCache } = await import('../../cacheFactory');
MockKeyvRedis.mockClear();
standardCache(CacheKeys.TOOL_CACHE);
expect(MockKeyvRedis).not.toHaveBeenCalled();
standardCache(CacheKeys.CONFIG_STORE);
expect(MockKeyvRedis).toHaveBeenCalledTimes(1);
});
});

View file

@ -27,9 +27,14 @@ const USE_REDIS_STREAMS =
// 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())
: [];
// Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container.
// Set to empty string to force all namespaces through Redis.
const FORCED_IN_MEMORY_CACHE_NAMESPACES =
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES !== undefined
? process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES.split(',')
.map((key) => key.trim())
.filter(Boolean)
: [CacheKeys.CONFIG_STORE, CacheKeys.APP_CONFIG];
// Validate against CacheKeys enum
if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {

View file

@ -1364,6 +1364,10 @@ export enum CacheKeys {
* Key for the config store namespace.
*/
CONFIG_STORE = 'CONFIG_STORE',
/**
* Key for the tool cache namespace (plugins, MCP tools, tool definitions).
*/
TOOL_CACHE = 'TOOL_CACHE',
/**
* Key for the roles cache.
*/