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:
13
.env.example
13
.env.example
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user