feat(redis): harden Redis module startup and shutdown behavior

This commit is contained in:
2026-03-29 18:48:25 +08:00
parent 114d6ff2f5
commit 4dfefadc9e

View File

@@ -1,47 +1,132 @@
import { Module, Global, Logger } from '@nestjs/common'; import {
Inject,
Injectable,
Logger,
Module,
Global,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis'; import Redis from 'ioredis';
import {
parsePositiveInteger,
parseRedisRequired,
} from '../common/config/env.utils';
export const REDIS_CLIENT = 'REDIS_CLIENT'; export const REDIS_CLIENT = 'REDIS_CLIENT';
export const REDIS_SUBSCRIBER_CLIENT = 'REDIS_SUBSCRIBER_CLIENT'; export const REDIS_SUBSCRIBER_CLIENT = 'REDIS_SUBSCRIBER_CLIENT';
const DEFAULT_REDIS_STARTUP_RETRIES = 10;
@Injectable()
class RedisLifecycleService implements OnModuleDestroy {
private readonly logger = new Logger(RedisLifecycleService.name);
constructor(
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
@Inject(REDIS_SUBSCRIBER_CLIENT)
private readonly redisSubscriber: Redis | null,
) {}
async onModuleDestroy(): Promise<void> {
const clients = [this.redisClient, this.redisSubscriber].filter(
(client): client is Redis => client !== null,
);
if (clients.length === 0) {
return;
}
await Promise.all(
clients.map(async (client) => {
try {
await client.quit();
} catch (error) {
this.logger.warn(
'Redis quit failed, forcing disconnect',
error as Error,
);
client.disconnect();
}
}),
);
}
}
@Global() @Global()
@Module({ @Module({
providers: [ providers: [
{ {
provide: REDIS_CLIENT, provide: REDIS_CLIENT,
useFactory: (configService: ConfigService) => { useFactory: async (configService: ConfigService) => {
const logger = new Logger('RedisModule'); const logger = new Logger('RedisModule');
const host = configService.get<string>('REDIS_HOST'); const host = configService.get<string>('REDIS_HOST');
const port = configService.get<number>('REDIS_PORT'); const port = parsePositiveInteger(
const password = configService.get<string>('REDIS_PASSWORD'); configService.get<string>('REDIS_PORT'),
6379,
// Fallback or "disabled" mode if no host is provided
if (!host) {
logger.warn(
'REDIS_HOST not defined. Redis features will be disabled or fall back to local memory.',
); );
const password = configService.get<string>('REDIS_PASSWORD');
const connectTimeout = parsePositiveInteger(
configService.get<string>('REDIS_CONNECT_TIMEOUT_MS'),
5000,
);
const redisRequired = parseRedisRequired({
nodeEnv: configService.get<string>('NODE_ENV'),
redisRequired: configService.get<string>('REDIS_REQUIRED'),
});
const startupRetries = parsePositiveInteger(
configService.get<string>('REDIS_STARTUP_RETRIES'),
DEFAULT_REDIS_STARTUP_RETRIES,
);
if (!host) {
if (redisRequired) {
throw new Error(
'REDIS_REQUIRED is enabled but REDIS_HOST is not configured',
);
}
logger.warn('REDIS_HOST not defined. Redis features are disabled.');
return null; return null;
} }
const client = new Redis({ const client = new Redis({
host, host,
port: port || 6379, port,
password: password, password,
// Retry strategy: keep trying to reconnect lazyConnect: true,
connectTimeout,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
retryStrategy(times) { retryStrategy(times) {
if (times > startupRetries) {
return null;
}
const delay = Math.min(times * 50, 2000); const delay = Math.min(times * 50, 2000);
return delay; return delay;
}, },
}); });
client.on('error', (err) => { client.on('connect', () => {
logger.error('Redis connection error', err); logger.log(`Connected to Redis at ${host}:${port}`);
}); });
client.on('connect', () => { try {
logger.log(`Connected to Redis at ${host}:${port || 6379}`); await client.connect();
}); await client.ping();
} catch {
client.disconnect();
if (redisRequired) {
throw new Error(
`Failed to connect to required Redis at ${host}:${port}`,
);
}
logger.warn('Redis connection failed; Redis features are disabled.');
return null;
}
return client; return client;
}, },
@@ -49,11 +134,26 @@ export const REDIS_SUBSCRIBER_CLIENT = 'REDIS_SUBSCRIBER_CLIENT';
}, },
{ {
provide: REDIS_SUBSCRIBER_CLIENT, provide: REDIS_SUBSCRIBER_CLIENT,
useFactory: (configService: ConfigService) => { useFactory: async (configService: ConfigService) => {
const logger = new Logger('RedisSubscriberModule'); const logger = new Logger('RedisSubscriberModule');
const host = configService.get<string>('REDIS_HOST'); const host = configService.get<string>('REDIS_HOST');
const port = configService.get<number>('REDIS_PORT'); const port = parsePositiveInteger(
configService.get<string>('REDIS_PORT'),
6379,
);
const password = configService.get<string>('REDIS_PASSWORD'); const password = configService.get<string>('REDIS_PASSWORD');
const connectTimeout = parsePositiveInteger(
configService.get<string>('REDIS_CONNECT_TIMEOUT_MS'),
5000,
);
const redisRequired = parseRedisRequired({
nodeEnv: configService.get<string>('NODE_ENV'),
redisRequired: configService.get<string>('REDIS_REQUIRED'),
});
const startupRetries = parsePositiveInteger(
configService.get<string>('REDIS_STARTUP_RETRIES'),
DEFAULT_REDIS_STARTUP_RETRIES,
);
if (!host) { if (!host) {
return null; return null;
@@ -61,9 +161,17 @@ export const REDIS_SUBSCRIBER_CLIENT = 'REDIS_SUBSCRIBER_CLIENT';
const client = new Redis({ const client = new Redis({
host, host,
port: port || 6379, port,
password: password, password,
lazyConnect: true,
connectTimeout,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
retryStrategy(times) { retryStrategy(times) {
if (times > startupRetries) {
return null;
}
const delay = Math.min(times * 50, 2000); const delay = Math.min(times * 50, 2000);
return delay; return delay;
}, },
@@ -82,10 +190,29 @@ export const REDIS_SUBSCRIBER_CLIENT = 'REDIS_SUBSCRIBER_CLIENT';
logger.error('Redis subscriber connection error', err); logger.error('Redis subscriber connection error', err);
}); });
try {
await client.connect();
await client.ping();
} catch {
client.disconnect();
if (redisRequired) {
throw new Error(
`Failed to connect to required Redis subscriber at ${host}:${port}`,
);
}
logger.warn(
'Redis subscriber connection failed; cross-instance subscriptions are disabled.',
);
return null;
}
return client; return client;
}, },
inject: [ConfigService], inject: [ConfigService],
}, },
RedisLifecycleService,
], ],
exports: [REDIS_CLIENT, REDIS_SUBSCRIBER_CLIENT], exports: [REDIS_CLIENT, REDIS_SUBSCRIBER_CLIENT],
}) })