feat(api): split Redis connection for BullMQ vs cache (RFC-004 Phase 3 WS1)

Introduce getRedisConnection('cache' | 'queue') so ops can point BullMQ at
a separate Redis instance from the cache/throttler/ws-adapter without a
code change. Falls back to REDIS_HOST/PORT/PASSWORD when REDIS_QUEUE_*
vars are unset, so dev and single-instance deploys are unchanged.

- New helper + describeRedisTopology() (safe summary, never leaks password)
- BullModule.forRoot now uses the queue connection
- .env.example documents optional REDIS_QUEUE_HOST/PORT/PASSWORD
- 6 unit tests cover defaults, fallback, precedence, shared/split topology,
  and password leak prevention

Refs: GOO-175

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 14:18:00 +07:00
parent b2490e209e
commit 455c959f44
4 changed files with 154 additions and 5 deletions

View File

@@ -32,6 +32,19 @@ REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
# -----------------------------------------------------------------------------
# Redis — Queue (BullMQ)
#
# RFC-004 Phase 3: the async backbone (BullMQ) can point at a Redis instance
# separate from cache / throttler / websocket to keep hot cache traffic from
# starving queue operations. If unset, queue traffic falls back to the cache
# REDIS_* vars above (single-instance dev and small deployments keep working
# unchanged).
# -----------------------------------------------------------------------------
# REDIS_QUEUE_HOST=
# REDIS_QUEUE_PORT=
# REDIS_QUEUE_PASSWORD=
# -----------------------------------------------------------------------------
# Typesense
# -----------------------------------------------------------------------------

View File

@@ -29,6 +29,7 @@ import { SharedModule } from '@modules/shared';
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
import { getRedisConnection } from '@modules/shared/infrastructure/redis-connection.config';
import { SubscriptionsModule } from '@modules/subscriptions';
import { TransferModule } from '@modules/transfer';
import { AppController } from './app.controller';
@@ -37,11 +38,11 @@ import { AppController } from './app.controller';
imports: [
SentryModule.forRoot(),
BullModule.forRoot({
connection: {
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
},
// RFC-004 Phase 3 — use the queue-specific Redis connection so ops can
// split cache traffic from queue traffic without a code change. Falls
// back to REDIS_HOST/PORT/PASSWORD when the queue-specific vars are
// unset. See shared/infrastructure/redis-connection.config.ts.
connection: getRedisConnection('queue'),
}),
CqrsModule.forRoot(),
ScheduleModule.forRoot(),

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describeRedisTopology, getRedisConnection } from '../redis-connection.config';
const KEYS = [
'REDIS_HOST',
'REDIS_PORT',
'REDIS_PASSWORD',
'REDIS_QUEUE_HOST',
'REDIS_QUEUE_PORT',
'REDIS_QUEUE_PASSWORD',
] as const;
describe('redis-connection.config', () => {
let original: Record<string, string | undefined>;
beforeEach(() => {
original = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
for (const k of KEYS) delete process.env[k];
});
afterEach(() => {
for (const k of KEYS) {
if (original[k] === undefined) delete process.env[k];
else process.env[k] = original[k];
}
});
it('defaults cache and queue to localhost:6379 when nothing is set', () => {
expect(getRedisConnection('cache')).toEqual({ host: 'localhost', port: 6379, password: undefined });
expect(getRedisConnection('queue')).toEqual({ host: 'localhost', port: 6379, password: undefined });
});
it('queue falls back to cache vars when queue-specific vars are unset', () => {
process.env['REDIS_HOST'] = 'cache.internal';
process.env['REDIS_PORT'] = '6380';
process.env['REDIS_PASSWORD'] = 'pw';
expect(getRedisConnection('queue')).toEqual({ host: 'cache.internal', port: 6380, password: 'pw' });
});
it('queue vars take precedence when set', () => {
process.env['REDIS_HOST'] = 'cache.internal';
process.env['REDIS_QUEUE_HOST'] = 'queue.internal';
process.env['REDIS_QUEUE_PORT'] = '6400';
process.env['REDIS_QUEUE_PASSWORD'] = 'qpw';
expect(getRedisConnection('queue')).toEqual({ host: 'queue.internal', port: 6400, password: 'qpw' });
expect(getRedisConnection('cache').host).toBe('cache.internal');
});
it('describeRedisTopology reports shared=true when cache and queue resolve to the same host/port', () => {
process.env['REDIS_HOST'] = 'one';
process.env['REDIS_PORT'] = '6379';
const t = describeRedisTopology();
expect(t.shared).toBe(true);
expect(t.cache.passwordSet).toBe(false);
});
it('describeRedisTopology reports shared=false when queue is split off', () => {
process.env['REDIS_HOST'] = 'one';
process.env['REDIS_QUEUE_HOST'] = 'two';
process.env['REDIS_PASSWORD'] = 'pw';
const t = describeRedisTopology();
expect(t.shared).toBe(false);
expect(t.queue.host).toBe('two');
expect(t.cache.passwordSet).toBe(true);
});
it('never leaks the password through describeRedisTopology', () => {
process.env['REDIS_PASSWORD'] = 'super-secret';
const t = describeRedisTopology();
expect(JSON.stringify(t)).not.toContain('super-secret');
});
});

View File

@@ -0,0 +1,63 @@
/**
* Redis connection configuration helpers.
*
* RFC-004 Phase 3 — workstream 1: split the Redis connection used by BullMQ
* from the connection used for cache / throttler / websocket adapter.
*
* Contract:
* - Cache / throttler / ws adapter: read REDIS_HOST / REDIS_PORT / REDIS_PASSWORD.
* - Queue (BullMQ): read REDIS_QUEUE_HOST / REDIS_QUEUE_PORT / REDIS_QUEUE_PASSWORD,
* falling back to the cache vars so dev / single-instance deploys keep working
* with a single Redis.
* - In production, split connections are recommended so a hot cache path cannot
* starve queue operations (and vice versa). The two hosts can still point at
* the same server; the split exists so ops can point them elsewhere without a
* code change.
*/
export type RedisConnectionPurpose = 'cache' | 'queue';
export interface RedisConnectionOptions {
host: string;
port: number;
password: string | undefined;
}
function readCacheConnection(): RedisConnectionOptions {
return {
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
};
}
function readQueueConnection(): RedisConnectionOptions {
const cache = readCacheConnection();
return {
host: process.env['REDIS_QUEUE_HOST'] ?? cache.host,
port: Number(process.env['REDIS_QUEUE_PORT'] ?? cache.port),
password: process.env['REDIS_QUEUE_PASSWORD'] ?? cache.password,
};
}
export function getRedisConnection(purpose: RedisConnectionPurpose): RedisConnectionOptions {
return purpose === 'queue' ? readQueueConnection() : readCacheConnection();
}
/**
* Returns a loggable summary of how cache vs queue connections are bound.
* Never includes the password — only host, port, and whether a password is set.
*/
export function describeRedisTopology(): {
cache: { host: string; port: number; passwordSet: boolean };
queue: { host: string; port: number; passwordSet: boolean };
shared: boolean;
} {
const cache = readCacheConnection();
const queue = readQueueConnection();
return {
cache: { host: cache.host, port: cache.port, passwordSet: Boolean(cache.password) },
queue: { host: queue.host, port: queue.port, passwordSet: Boolean(queue.password) },
shared: cache.host === queue.host && cache.port === queue.port,
};
}