diff --git a/.env.example b/.env.example index 393fab7..d9d1312 100644 --- a/.env.example +++ b/.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 # ----------------------------------------------------------------------------- diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cc98d4e..e23427f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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(), diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/redis-connection.config.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/redis-connection.config.spec.ts new file mode 100644 index 0000000..3dcd439 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/redis-connection.config.spec.ts @@ -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; + + 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'); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/redis-connection.config.ts b/apps/api/src/modules/shared/infrastructure/redis-connection.config.ts new file mode 100644 index 0000000..484d93a --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/redis-connection.config.ts @@ -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, + }; +}