diff --git a/.env.example b/.env.example index 0cf51ea2dc..4fd526f569 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 5940689957..3089192196 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -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), diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index c5e074b8ff..279ffb15fd 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -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 })); diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index d7d3f83a8b..06a51a3bd6 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -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 diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js index 48ab6e0737..38d488ed38 100644 --- a/api/server/services/Config/__tests__/getCachedTools.spec.js +++ b/api/server/services/Config/__tests__/getCachedTools.spec.js @@ -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); + }); + }); }); diff --git a/api/server/services/Config/getCachedTools.js b/api/server/services/Config/getCachedTools.js index cf1618a646..eb7a08305a 100644 --- a/api/server/services/Config/getCachedTools.js +++ b/api/server/services/Config/getCachedTools.js @@ -20,7 +20,7 @@ const ToolCacheKeys = { * @returns {Promise} 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} 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} */ 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} 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) { diff --git a/api/server/services/Config/mcp.js b/api/server/services/Config/mcp.js index 15ea62a028..cc4e98b59e 100644 --- a/api/server/services/Config/mcp.js +++ b/api/server/services/Config/mcp.js @@ -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) { diff --git a/packages/api/src/cache/__tests__/cacheConfig.spec.ts b/packages/api/src/cache/__tests__/cacheConfig.spec.ts index e24f52fee0..0488cfecfc 100644 --- a/packages/api/src/cache/__tests__/cacheConfig.spec.ts +++ b/packages/api/src/cache/__tests__/cacheConfig.spec.ts @@ -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']); }); }); }); diff --git a/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts new file mode 100644 index 0000000000..9a8b4ff3bf --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/cache/cacheConfig.ts b/packages/api/src/cache/cacheConfig.ts index 32ea2cddd1..0d4304f5c3 100644 --- a/packages/api/src/cache/cacheConfig.ts +++ b/packages/api/src/cache/cacheConfig.ts @@ -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) { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 504811dbbe..a2b47351b1 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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. */