mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🧭 fix: Make MCP Catalog Redis Cluster-Safe (#14717)
* fix: make MCP catalog Redis startup cluster-safe * fix: stabilize Redis readiness gate * fix: type Redis readiness export * style: apply canonical import order
This commit is contained in:
parent
92d4705f79
commit
1bd4455c2d
9 changed files with 160 additions and 24 deletions
|
|
@ -37,6 +37,7 @@ const {
|
|||
updateInterfacePermissions,
|
||||
configureMessageFilterRegexValidator,
|
||||
configureFileConfigRegexEngine,
|
||||
waitForKeyvRedisClient,
|
||||
} = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
const {
|
||||
|
|
@ -115,6 +116,7 @@ const configureGenerationStreams = () => {
|
|||
};
|
||||
|
||||
const startServer = async () => {
|
||||
await waitForKeyvRedisClient();
|
||||
const { metricsMiddleware, metricsRouter } = createMetrics();
|
||||
if (!process.env.METRICS_SECRET) {
|
||||
logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests');
|
||||
|
|
|
|||
|
|
@ -110,6 +110,16 @@ describe('Telemetry wiring', () => {
|
|||
describe('Startup readiness wiring', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
|
||||
|
||||
it('awaits the shared Redis client before startup cache access', () => {
|
||||
const redisReadyIndex = source.indexOf('await waitForKeyvRedisClient();');
|
||||
const connectDbIndex = source.indexOf('await connectDb();');
|
||||
const appConfigIndex = source.indexOf('await getAppConfig({ baseOnly: true });');
|
||||
|
||||
expect(redisReadyIndex).toBeGreaterThan(-1);
|
||||
expect(connectDbIndex).toBeGreaterThan(redisReadyIndex);
|
||||
expect(appConfigIndex).toBeGreaterThan(redisReadyIndex);
|
||||
});
|
||||
|
||||
it('configures generation streams before the server accepts requests', () => {
|
||||
const streamConfigIndex = source.indexOf('configureGenerationStreams();');
|
||||
const listenIndex = source.indexOf('const server = app.listen');
|
||||
|
|
|
|||
|
|
@ -8,13 +8,20 @@ const mockRedisClient = {
|
|||
const mockKeyvRedisClient = {
|
||||
eval: jest.fn(),
|
||||
};
|
||||
let mockRedisReadyPromise = Promise.resolve();
|
||||
const mockRedisReady = {
|
||||
then: (...args) => mockRedisReadyPromise.then(...args),
|
||||
};
|
||||
const mockWaitForRedis = jest.fn(() => mockRedisReady);
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
cacheConfig: { FORCED_IN_MEMORY_CACHE_NAMESPACES: [] },
|
||||
evalKeyvRedisScript: (...args) => mockKeyvRedisClient.eval(...args),
|
||||
mcpConfig: { USER_CONNECTION_IDLE_TIMEOUT: 15 * 60 * 1000 },
|
||||
ioredisClient: mockRedisClient,
|
||||
keyvRedisClient: mockKeyvRedisClient,
|
||||
waitForKeyvRedisClient: mockWaitForRedis,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -47,6 +54,7 @@ describe('global tool cache write lock', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedisReadyPromise = Promise.resolve();
|
||||
mockRedisClient.set.mockResolvedValue('OK');
|
||||
mockRedisClient.eval.mockResolvedValue(1);
|
||||
mockKeyvRedisClient.eval.mockResolvedValue(1);
|
||||
|
|
@ -54,6 +62,43 @@ describe('global tool cache write lock', () => {
|
|||
mockCache.delete.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('waits for the shared Redis client before accessing the catalog', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
let resolveRedisReady;
|
||||
mockRedisReadyPromise = new Promise((resolve) => {
|
||||
resolveRedisReady = resolve;
|
||||
});
|
||||
|
||||
const update = updateCachedGlobalTools(() => ({ builtin: {} }));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockRedisClient.set).not.toHaveBeenCalled();
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
|
||||
resolveRedisReady();
|
||||
await expect(update).resolves.toBeUndefined();
|
||||
|
||||
expect(mockRedisClient.set).toHaveBeenCalledTimes(1);
|
||||
expect(mockCache.get).toHaveBeenCalledWith('tools:global');
|
||||
});
|
||||
|
||||
it('waits for the shared Redis client before reading the catalog', async () => {
|
||||
mockCache.get.mockResolvedValue({ builtin: {} });
|
||||
let resolveRedisReady;
|
||||
mockRedisReadyPromise = new Promise((resolve) => {
|
||||
resolveRedisReady = resolve;
|
||||
});
|
||||
|
||||
const read = getCachedTools();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
|
||||
resolveRedisReady();
|
||||
await expect(read).resolves.toEqual({ builtin: {} });
|
||||
expect(mockCache.get).toHaveBeenCalledWith('tools:global');
|
||||
});
|
||||
|
||||
it('acquires and safely releases the Redis lock around an aggregate update', async () => {
|
||||
const operation = jest.fn().mockResolvedValue('updated');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const {
|
||||
cacheConfig,
|
||||
evalKeyvRedisScript,
|
||||
ioredisClient,
|
||||
keyvRedisClient,
|
||||
waitForKeyvRedisClient,
|
||||
mcpConfig,
|
||||
ToolCacheKeys,
|
||||
createMCPCatalogStore,
|
||||
|
|
@ -12,7 +14,8 @@ const getLogStores = require('~/cache/getLogStores');
|
|||
const store = createMCPCatalogStore({
|
||||
cacheConfig,
|
||||
ioredisClient,
|
||||
keyvRedisClient,
|
||||
keyvRedisClient: keyvRedisClient ? { eval: evalKeyvRedisScript } : null,
|
||||
waitForRedis: waitForKeyvRedisClient,
|
||||
userConnectionIdleTimeout: mcpConfig.USER_CONNECTION_IDLE_TIMEOUT,
|
||||
getCache: () => getLogStores(CacheKeys.TOOL_CACHE),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue