mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 08:25:23 +02:00
* fix: Redis scalability improvements for high-throughput deployments Replace INCR+check+DECR race in concurrency middleware with atomic Lua scripts. The old approach allowed 3-4 concurrent requests through a limit of 2 at 300 req/s because another request could slip between the INCR returning and the DECR executing. The Lua scripts run atomically on the Redis server, eliminating the race window entirely. Add exponential backoff with jitter to all three Redis retry strategies (ioredis single-node, cluster, keyv). Previously all instances retried at the same millisecond after an outage, causing a connection storm. Batch the RedisJobStore cleanup loop into parallel chunks of 50. With 1000 stale jobs, this reduces cleanup from ~20s of sequential calls to ~2s. Also pipeline appendChunk (xadd + expire) into a single round-trip and refresh TTL on every chunk instead of only the first, preventing TTL expiry during long-running streams. Propagate publish errors in RedisEventTransport.emitDone and emitError so callers can detect dropped completion/error events. emitChunk is left as swallow-and-log because its callers fire-and-forget without await. Add jest.config.js for the API package with babel TypeScript support and path alias resolution. Fix existing stream integration tests that were silently broken due to missing USE_REDIS_CLUSTER=false env var. * chore: Migrate Jest configuration from jest.config.js to jest.config.mjs Removed the old jest.config.js file and integrated the Jest configuration into jest.config.mjs, adding Babel TypeScript support and path alias resolution. This change streamlines the configuration for the API package. * fix: Ensure Redis retry delays do not exceed maximum configured delay Updated the delay calculation in Redis retry strategies to enforce a maximum delay defined in the configuration. This change prevents excessive delays during reconnection attempts, improving overall connection stability and performance. * fix: Update RedisJobStore cleanup to handle job failures gracefully Changed the cleanup process in RedisJobStore to use Promise.allSettled instead of Promise.all, allowing for individual job failures to be logged without interrupting the entire cleanup operation. This enhances error handling and provides better visibility into issues during job cleanup.
145 lines
5.7 KiB
TypeScript
145 lines
5.7 KiB
TypeScript
/**
|
|
* @keyv/redis exports its default class in a non-standard way:
|
|
* module.exports = { default: KeyvRedis, ... } instead of module.exports = KeyvRedis
|
|
* This breaks ES6 imports when the module is marked as external in rollup.
|
|
* We must use require() to access the .default property directly.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const KeyvRedis = require('@keyv/redis').default as typeof import('@keyv/redis').default;
|
|
import { Keyv } from 'keyv';
|
|
import createMemoryStore from 'memorystore';
|
|
import { RedisStore } from 'rate-limit-redis';
|
|
import { Time } from 'librechat-data-provider';
|
|
import { logger } from '@librechat/data-schemas';
|
|
import session, { MemoryStore } from 'express-session';
|
|
import { RedisStore as ConnectRedis } from 'connect-redis';
|
|
import type { SendCommandFn } from 'rate-limit-redis';
|
|
import { keyvRedisClient, ioredisClient } from './redisClients';
|
|
import { cacheConfig } from './cacheConfig';
|
|
import { violationFile } from './keyvFiles';
|
|
import { batchDeleteKeys, scanKeys } from './redisUtils';
|
|
|
|
/**
|
|
* Creates a cache instance using Redis or a fallback store. Suitable for general caching needs.
|
|
* @param namespace - The cache namespace.
|
|
* @param ttl - Time to live for cache entries.
|
|
* @param fallbackStore - Optional fallback store if Redis is not used.
|
|
* @returns Cache instance.
|
|
*/
|
|
export const standardCache = (namespace: string, ttl?: number, fallbackStore?: object): Keyv => {
|
|
if (keyvRedisClient && !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)) {
|
|
try {
|
|
const keyvRedis = new KeyvRedis(keyvRedisClient);
|
|
const cache = new Keyv(keyvRedis, { namespace, ttl });
|
|
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
|
|
keyvRedis.keyPrefixSeparator = cacheConfig.GLOBAL_PREFIX_SEPARATOR;
|
|
|
|
cache.on('error', (err) => {
|
|
logger.error(`Cache error in namespace ${namespace}:`, err);
|
|
});
|
|
|
|
// Override clear() to handle namespace-aware deletion
|
|
// The default Keyv clear() doesn't respect namespace due to the workaround above
|
|
// Workaround for issue #10487 https://github.com/danny-avila/LibreChat/issues/10487
|
|
cache.clear = async () => {
|
|
// Type-safe check for Redis client with scanIterator support
|
|
if (!keyvRedisClient || !('scanIterator' in keyvRedisClient)) {
|
|
logger.warn(`Cannot clear namespace ${namespace}: Redis scanIterator not available`);
|
|
return;
|
|
}
|
|
|
|
// Build pattern: globalPrefix::namespace:* or namespace:*
|
|
const pattern = cacheConfig.REDIS_KEY_PREFIX
|
|
? `${cacheConfig.REDIS_KEY_PREFIX}${cacheConfig.GLOBAL_PREFIX_SEPARATOR}${namespace}:*`
|
|
: `${namespace}:*`;
|
|
|
|
// Use utility functions for efficient scan and parallel deletion
|
|
const keysToDelete = await scanKeys(keyvRedisClient, pattern);
|
|
|
|
if (keysToDelete.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await batchDeleteKeys(keyvRedisClient, keysToDelete);
|
|
logger.debug(`Cleared ${keysToDelete.length} keys from namespace ${namespace}`);
|
|
};
|
|
|
|
return cache;
|
|
} catch (err) {
|
|
logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
if (fallbackStore) {
|
|
return new Keyv({ store: fallbackStore, namespace, ttl });
|
|
}
|
|
return new Keyv({ namespace, ttl });
|
|
};
|
|
|
|
/**
|
|
* Creates a cache instance for storing violation data.
|
|
* Uses a file-based fallback store if Redis is not enabled.
|
|
* @param namespace - The cache namespace for violations.
|
|
* @param ttl - Time to live for cache entries.
|
|
* @returns Cache instance for violations.
|
|
*/
|
|
export const violationCache = (namespace: string, ttl?: number): Keyv => {
|
|
return standardCache(`violations:${namespace}`, ttl, violationFile);
|
|
};
|
|
|
|
/**
|
|
* Creates a session cache instance using Redis or in-memory store.
|
|
* @param namespace - The session namespace.
|
|
* @param ttl - Time to live for session entries.
|
|
* @returns Session store instance.
|
|
*/
|
|
export const sessionCache = (namespace: string, ttl?: number): MemoryStore | ConnectRedis => {
|
|
namespace = namespace.endsWith(':') ? namespace : `${namespace}:`;
|
|
if (!cacheConfig.USE_REDIS) {
|
|
const MemoryStore = createMemoryStore(session);
|
|
return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY });
|
|
}
|
|
const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
|
|
if (ioredisClient) {
|
|
ioredisClient.on('error', (err) => {
|
|
logger.error(`Session store Redis error for namespace ${namespace}:`, err);
|
|
});
|
|
}
|
|
return store;
|
|
};
|
|
|
|
/**
|
|
* Creates a rate limiter cache using Redis.
|
|
* @param prefix - The key prefix for rate limiting.
|
|
* @returns RedisStore instance or undefined if Redis is not used.
|
|
*/
|
|
export const limiterCache = (prefix: string): RedisStore | undefined => {
|
|
if (!prefix) {
|
|
throw new Error('prefix is required');
|
|
}
|
|
if (!cacheConfig.USE_REDIS) {
|
|
return undefined;
|
|
}
|
|
// Note: The `prefix` is applied by RedisStore internally to its key operations.
|
|
// The global REDIS_KEY_PREFIX is applied by ioredisClient's keyPrefix setting.
|
|
// Combined key format: `{REDIS_KEY_PREFIX}::{prefix}{identifier}`
|
|
prefix = prefix.endsWith(':') ? prefix : `${prefix}:`;
|
|
|
|
try {
|
|
const sendCommand: SendCommandFn = (async (...args: string[]) => {
|
|
if (ioredisClient == null) {
|
|
throw new Error('Redis client not available');
|
|
}
|
|
try {
|
|
return await ioredisClient.call(args[0], ...args.slice(1));
|
|
} catch (err) {
|
|
logger.error('Redis command execution failed:', err);
|
|
throw err;
|
|
}
|
|
}) as SendCommandFn;
|
|
return new RedisStore({ sendCommand, prefix });
|
|
} catch (err) {
|
|
logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err);
|
|
return undefined;
|
|
}
|
|
};
|