🧩 fix: Add REDIS_CLUSTER_SAFE_DELETE Flag for ElastiCache Serverless CROSSSLOT Errors (#13275)

* 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 <danny@librechat.ai>
This commit is contained in:
Serhii Zghama 2026-05-25 01:49:40 +07:00 committed by GitHub
parent cdcedc1fd6
commit 9cd2fc2ed6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 48 additions and 3 deletions

View file

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

View file

@ -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';

View file

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

View file

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