🗃️ 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

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