From 9cd2fc2ed650765e82203127d1e344d55150320f Mon Sep 17 00:00:00 2001 From: Serhii Zghama <20826225+serhiizghama@users.noreply.github.com> Date: Mon, 25 May 2026 01:49:40 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A9=20fix:=20Add=20REDIS=5FCLUSTER=5FS?= =?UTF-8?q?AFE=5FDELETE=20Flag=20for=20ElastiCache=20Serverless=20CROSSSLO?= =?UTF-8?q?T=20Errors=20(#13275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(redis): add REDIS_CLUSTER_SAFE_DELETE for ElastiCache Serverless CROSSSLOT errors ElastiCache Serverless and similar managed Redis services present a single-node connection endpoint but shard keys internally. When USE_REDIS_CLUSTER=false (as required for single-endpoint services), batchDeleteKeys() uses multi-key DEL commands that fail with CROSSSLOT errors because the managed cluster rejects cross-slot operations. Adds REDIS_CLUSTER_SAFE_DELETE=true which forces per-key deletion (the same cluster-safe path) without changing the connection mode. This makes the delete strategy independent of the connection topology. Closes #13261 * test(cache): add REDIS_CLUSTER_SAFE_DELETE config tests * fix: Avoid nested Redis delete mode ternary * docs: Add Redis cluster-safe delete env example --------- Co-authored-by: Danny Avila --- .env.example | 6 +++++ .../src/cache/__tests__/cacheConfig.spec.ts | 22 +++++++++++++++++++ packages/api/src/cache/cacheConfig.ts | 7 ++++++ packages/api/src/cache/redisUtils.ts | 16 +++++++++++--- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 645a44b833..cb8653ad40 100644 --- a/.env.example +++ b/.env.example @@ -813,6 +813,12 @@ HELP_AND_FAQ_URL=https://librechat.ai # Redis cluster (multiple nodes) # REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 +# Enable Redis cluster mode when connecting to a cluster through a single URI +# USE_REDIS_CLUSTER=true + +# Managed Redis services with a single endpoint may shard keys internally and reject multi-key DEL +# Set to true to delete keys individually and avoid CROSSSLOT errors while keeping single-node mode +# REDIS_CLUSTER_SAFE_DELETE=true # Redis with TLS/SSL encryption and CA certificate # REDIS_URI=rediss://127.0.0.1:6380 diff --git a/packages/api/src/cache/__tests__/cacheConfig.spec.ts b/packages/api/src/cache/__tests__/cacheConfig.spec.ts index 0488cfecfc..820815b5f5 100644 --- a/packages/api/src/cache/__tests__/cacheConfig.spec.ts +++ b/packages/api/src/cache/__tests__/cacheConfig.spec.ts @@ -12,6 +12,7 @@ describe('cacheConfig', () => { delete process.env.USE_REDIS; delete process.env.USE_REDIS_STREAMS; delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_CLUSTER_SAFE_DELETE; delete process.env.REDIS_PING_INTERVAL; delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; @@ -131,6 +132,27 @@ describe('cacheConfig', () => { }); }); + describe('REDIS_CLUSTER_SAFE_DELETE configuration', () => { + test('should default to false when REDIS_CLUSTER_SAFE_DELETE is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.REDIS_CLUSTER_SAFE_DELETE).toBe(false); + }); + + test('should be false when REDIS_CLUSTER_SAFE_DELETE is set to false', async () => { + process.env.REDIS_CLUSTER_SAFE_DELETE = 'false'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.REDIS_CLUSTER_SAFE_DELETE).toBe(false); + }); + + test('should be true when REDIS_CLUSTER_SAFE_DELETE is set to true', async () => { + process.env.REDIS_CLUSTER_SAFE_DELETE = 'true'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.REDIS_CLUSTER_SAFE_DELETE).toBe(true); + }); + }); + describe('USE_REDIS_STREAMS configuration', () => { test('should default to USE_REDIS value when USE_REDIS_STREAMS is not set', async () => { process.env.USE_REDIS = 'true'; diff --git a/packages/api/src/cache/cacheConfig.ts b/packages/api/src/cache/cacheConfig.ts index 7b4a899e98..cbccffbf59 100644 --- a/packages/api/src/cache/cacheConfig.ts +++ b/packages/api/src/cache/cacheConfig.ts @@ -95,6 +95,13 @@ const cacheConfig = { REDIS_USE_ALTERNATIVE_DNS_LOOKUP: isEnabled(process.env.REDIS_USE_ALTERNATIVE_DNS_LOOKUP), /** Enable redis cluster without the need of multiple URIs */ USE_REDIS_CLUSTER: isEnabled(process.env.USE_REDIS_CLUSTER ?? 'false'), + /** + * Force cluster-safe (key-by-key) deletion even when connecting as a single-node Redis instance. + * Needed for managed services like ElastiCache Serverless that present a single endpoint + * but shard keys internally, causing CROSSSLOT errors on multi-key DEL commands. + * Has no effect when USE_REDIS_CLUSTER is already true. + */ + REDIS_CLUSTER_SAFE_DELETE: isEnabled(process.env.REDIS_CLUSTER_SAFE_DELETE ?? 'false'), CI: isEnabled(process.env.CI), DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE), diff --git a/packages/api/src/cache/redisUtils.ts b/packages/api/src/cache/redisUtils.ts index 334fe1e82a..de37c8ba5c 100644 --- a/packages/api/src/cache/redisUtils.ts +++ b/packages/api/src/cache/redisUtils.ts @@ -31,11 +31,21 @@ export async function batchDeleteKeys( } const size = chunkSize ?? cacheConfig.REDIS_DELETE_CHUNK_SIZE; - const mode = cacheConfig.USE_REDIS_CLUSTER ? 'cluster' : 'single-node'; - const deletePromises = []; + const clusterSafe = cacheConfig.USE_REDIS_CLUSTER || cacheConfig.REDIS_CLUSTER_SAFE_DELETE; + let mode = 'single-node'; if (cacheConfig.USE_REDIS_CLUSTER) { - // Cluster mode: Delete each key individually in parallel chunks to avoid CROSSSLOT errors + mode = 'cluster'; + } else if (cacheConfig.REDIS_CLUSTER_SAFE_DELETE) { + mode = 'cluster-safe'; + } + + const deletePromises = []; + + if (clusterSafe) { + // Cluster / cluster-safe mode: Delete each key individually in parallel chunks to avoid CROSSSLOT errors. + // Also used when REDIS_CLUSTER_SAFE_DELETE=true for managed services like ElastiCache Serverless that + // shard keys internally while presenting a single-node connection endpoint. for (let i = 0; i < keys.length; i += size) { const chunk = keys.slice(i, i + size); deletePromises.push(Promise.all(chunk.map((key) => client.del(key))));