|
|
import Redis from "ioredis";
|
|
|
import type { RedisOptions } from "ioredis";
|
|
|
import { execSync } from "node:child_process";
|
|
|
|
|
|
let redis: Redis | null = null;
|
|
|
|
|
|
const REDIS_RETRY_DEFAULT_ATTEMPTS = 12;
|
|
|
const REDIS_RETRY_DEFAULT_DELAY_MS = 600;
|
|
|
const DEFAULT_WSL_DISTRO = "Ubuntu-22.04";
|
|
|
|
|
|
const REDIS_IO_OPTIONS: Pick<
|
|
|
RedisOptions,
|
|
|
"family" | "maxRetriesPerRequest" | "enableReadyCheck" | "retryStrategy"
|
|
|
> = {
|
|
|
/** WSL localhost 转发仅稳定支持 IPv4;避免 ::1 ECONNREFUSED */
|
|
|
family: 4,
|
|
|
/** BullMQ + 长连接 worker 必须为 null,否则断线时心跳抛 MaxRetriesPerRequestError */
|
|
|
maxRetriesPerRequest: null,
|
|
|
enableReadyCheck: true,
|
|
|
retryStrategy: (times) => Math.min(times * 500, 5_000),
|
|
|
};
|
|
|
|
|
|
/** BullMQ / ioredis 连接选项 */
|
|
|
export function getRedisConnectionOptions(): RedisOptions {
|
|
|
const url = process.env.REDIS_URL;
|
|
|
if (!url) {
|
|
|
throw new Error("REDIS_URL 未配置");
|
|
|
}
|
|
|
const parsed = new URL(url);
|
|
|
return {
|
|
|
...REDIS_IO_OPTIONS,
|
|
|
host: parsed.hostname,
|
|
|
port: Number(parsed.port || 6379),
|
|
|
...(parsed.password ? { password: parsed.password } : {}),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function resetRedisClient(): void {
|
|
|
if (redis) {
|
|
|
redis.disconnect();
|
|
|
redis = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export function getRedis(): Redis {
|
|
|
if (!redis) {
|
|
|
const url = process.env.REDIS_URL;
|
|
|
if (!url) {
|
|
|
const error = new Error("REDIS_URL 未配置");
|
|
|
console.error("[redis] 连接失败:", error.message);
|
|
|
throw error;
|
|
|
}
|
|
|
|
|
|
redis = new Redis(url, REDIS_IO_OPTIONS);
|
|
|
|
|
|
redis.on("error", (err) => {
|
|
|
console.error("[redis] 运行时错误:", err);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
return redis;
|
|
|
}
|
|
|
|
|
|
function wakeWslRedisForward(): void {
|
|
|
if (process.platform !== "win32") {
|
|
|
return;
|
|
|
}
|
|
|
const distro = process.env.WSL_DISTRO?.trim() || DEFAULT_WSL_DISTRO;
|
|
|
try {
|
|
|
execSync(`wsl -d ${distro} -- redis-cli PING`, {
|
|
|
stdio: "ignore",
|
|
|
timeout: 8_000,
|
|
|
windowsHide: true,
|
|
|
});
|
|
|
} catch {
|
|
|
/* 唤醒失败不阻断重试 */
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** WSL IP 失效时回退 127.0.0.1(.dev/infra.env 可能写入过期的 172.x) */
|
|
|
export function buildRedisCandidateUrls(primary?: string): string[] {
|
|
|
const base = primary?.trim() || process.env.REDIS_URL?.trim();
|
|
|
if (!base) {
|
|
|
return [];
|
|
|
}
|
|
|
const urls: string[] = [base];
|
|
|
try {
|
|
|
const parsed = new URL(base);
|
|
|
const port = parsed.port || "6379";
|
|
|
const host = parsed.hostname.toLowerCase();
|
|
|
if (host !== "127.0.0.1") {
|
|
|
urls.push(`redis://127.0.0.1:${port}`);
|
|
|
}
|
|
|
if (host !== "localhost") {
|
|
|
urls.push(`redis://localhost:${port}`);
|
|
|
}
|
|
|
} catch {
|
|
|
/* 保留 base */
|
|
|
}
|
|
|
return [...new Set(urls)];
|
|
|
}
|
|
|
|
|
|
async function probeRedisUrl(
|
|
|
url: string,
|
|
|
connectTimeoutMs: number,
|
|
|
): Promise<boolean> {
|
|
|
const probe = new Redis(url, {
|
|
|
...REDIS_IO_OPTIONS,
|
|
|
maxRetriesPerRequest: 1,
|
|
|
connectTimeout: connectTimeoutMs,
|
|
|
lazyConnect: true,
|
|
|
});
|
|
|
try {
|
|
|
await probe.connect();
|
|
|
const pong = await probe.ping();
|
|
|
return pong === "PONG";
|
|
|
} catch {
|
|
|
return false;
|
|
|
} finally {
|
|
|
await probe.quit().catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function adoptRedisUrl(url: string): void {
|
|
|
if (process.env.REDIS_URL === url) {
|
|
|
return;
|
|
|
}
|
|
|
process.env.REDIS_URL = url;
|
|
|
resetRedisClient();
|
|
|
}
|
|
|
|
|
|
/** 新建连接探针(不复用单例),用于验收前摇 */
|
|
|
export async function probeFreshRedisConnection(): Promise<void> {
|
|
|
const url = process.env.REDIS_URL;
|
|
|
if (!url) {
|
|
|
throw new Error("REDIS_URL 未配置");
|
|
|
}
|
|
|
const probe = new Redis(url, {
|
|
|
...REDIS_IO_OPTIONS,
|
|
|
maxRetriesPerRequest: 1,
|
|
|
connectTimeout: 4_000,
|
|
|
lazyConnect: true,
|
|
|
});
|
|
|
try {
|
|
|
await probe.connect();
|
|
|
const pong = await probe.ping();
|
|
|
if (pong !== "PONG") {
|
|
|
throw new Error(`Redis ping=${pong}`);
|
|
|
}
|
|
|
} finally {
|
|
|
await probe.quit().catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** worker 启动前等待 Redis 可达(WSL localhost 转发有冷启动延迟) */
|
|
|
export async function ensureRedisReady(
|
|
|
maxAttempts = REDIS_RETRY_DEFAULT_ATTEMPTS,
|
|
|
delayMs = REDIS_RETRY_DEFAULT_DELAY_MS,
|
|
|
): Promise<void> {
|
|
|
const candidates = buildRedisCandidateUrls();
|
|
|
if (candidates.length === 0) {
|
|
|
throw new Error("REDIS_URL 未配置");
|
|
|
}
|
|
|
|
|
|
let consecutiveRefused = 0;
|
|
|
let lastError = "Redis 连接失败";
|
|
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
|
for (const url of candidates) {
|
|
|
const ok = await probeRedisUrl(url, 4_000);
|
|
|
if (ok) {
|
|
|
adoptRedisUrl(url);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
lastError = `Connection is closed.(${candidates.join(" / ")})`;
|
|
|
consecutiveRefused += 1;
|
|
|
if (
|
|
|
process.env.DEV_INFRA_MODE?.trim().toLowerCase() === "native" &&
|
|
|
(consecutiveRefused === 1 || consecutiveRefused % 2 === 0)
|
|
|
) {
|
|
|
wakeWslRedisForward();
|
|
|
}
|
|
|
|
|
|
if (attempt === maxAttempts) {
|
|
|
throw new Error(
|
|
|
`${lastError},已重试 ${maxAttempts} 次;请先 npm run dev:infra:start`,
|
|
|
);
|
|
|
}
|
|
|
const backoffMs =
|
|
|
attempt <= 4 ? delayMs : attempt <= 8 ? delayMs * 2 : delayMs * 3;
|
|
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const BOUNDED_REDIS_CONNECT_MS = 3_000;
|
|
|
|
|
|
/** API 热路径短连接:Redis 不可达时快速失败,避免 getRedis 单例无限重试拖慢请求 */
|
|
|
export async function withBoundedRedis<T>(
|
|
|
fn: (client: Redis) => Promise<T>,
|
|
|
): Promise<T | null> {
|
|
|
const candidates = buildRedisCandidateUrls();
|
|
|
if (candidates.length === 0) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
for (const url of candidates) {
|
|
|
const client = new Redis(url, {
|
|
|
family: 4,
|
|
|
connectTimeout: BOUNDED_REDIS_CONNECT_MS,
|
|
|
maxRetriesPerRequest: 1,
|
|
|
lazyConnect: true,
|
|
|
retryStrategy: () => null,
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
await client.connect();
|
|
|
const result = await fn(client);
|
|
|
adoptRedisUrl(url);
|
|
|
return result;
|
|
|
} catch {
|
|
|
/* 尝试下一个候选 URL */
|
|
|
} finally {
|
|
|
await client.quit().catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|