forked from lunanting/cangku
远端 RST 会把隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。原先的 实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。 1) scripts/start-remote.mjs —— 隧道断了却拉着应用陪葬 隧道进程 exit 时直接 stopChildren(),把后端和前端一起杀掉,于是每一次 RST 都 升级成一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。 改为「退避重连隧道本身,后端/前端进程不受影响」——这正是 autossh 的语义 (autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数 退避,建立成功即重置退避。 顺带补齐保活参数:TCPKeepAlive=yes、ConnectTimeout=10,探活间隔 30s → 15s, 以便更快发现已被对端 RST 的死连接。 2) backend/src/config/cloudDatabase.js —— 配置负缓存被永久固化 系统库读取失败时把「空候选」写进缓存,而入口判断是 `if (cachedCloudConfigCandidates)` —— [] 在 JS 里是 truthy,于是这次失败被 永久固化:之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用, 必须重启后端。 改为「带 TTL 的负缓存」(默认 30s,可用 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS 调整): 入口用 `!== null` 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。 缓存清理收敛到 invalidateCloudConfigCache() 单点,避免日后只清数组、忘了清 TTL 时间戳而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完 配置点测试能立即看到真实结果。 顺带把请求超时默认值 600000ms → 60000ms:10 分钟 × maxAttempts=2 最坏挂 20 分钟,用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。 验证: - 新增 backend/test/cloudDatabaseConfigCache.test.js,锁定负缓存三段语义 (TTL 内不重复解析 / 到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰); test/remote-access.test.mjs 补隧道保活参数、退避曲线,以及「自愈循环不得调用 stopChildren」的防腐化断言。 - 两处均做对照组实验:去掉保护后测试如实报错(负缓存 → 报 TTL 到期未重试; 隧道 → 把 stopChildren 加回去即被用例抓住),证明用例真能拦住回归。 - 后端全量 340 项 / 339 通过,唯一失败为 resilientSecretStore 的 Windows 权限 断言(环境固有,与本次无关);check-syntax 通过。 - 本地实测:kill 掉隧道进程后 1 秒内自动重建(PID 36268 → 15816),后端进程 PID 未变(29580)、/health 仍返回 200。
127 lines
4.6 KiB
JavaScript
127 lines
4.6 KiB
JavaScript
/**
|
||
* 配置负缓存 TTL 回归测试。
|
||
*
|
||
* 背景(线上真实故障):
|
||
* 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来
|
||
* (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy),
|
||
* 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。
|
||
*
|
||
* 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。
|
||
* 本文件锁定三条语义:
|
||
* ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时)
|
||
* ② TTL 到期后必须重试
|
||
* ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰
|
||
*/
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
// ── 必须在加载被测模块之前完成环境与替身准备 ─────────────────────────────
|
||
// 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置)
|
||
for (const key of Object.keys(process.env)) {
|
||
if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) {
|
||
delete process.env[key];
|
||
}
|
||
}
|
||
// 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉
|
||
process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150';
|
||
|
||
// 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool
|
||
const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise');
|
||
const realMysqlPromise = require(MYSQL_PROMISE_PATH);
|
||
|
||
let systemDbDialAttempts = 0;
|
||
let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复
|
||
|
||
const systemConfigRow = {
|
||
name: 'cloud_database',
|
||
host: '127.0.0.1',
|
||
port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库
|
||
database_name: 'JSReportReplica',
|
||
username: 'probe',
|
||
password: 'probe',
|
||
ssl: 0,
|
||
driver: 'mssql',
|
||
use_env_config: 0,
|
||
is_default: 1,
|
||
};
|
||
|
||
require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, {
|
||
createPool: () => {
|
||
systemDbDialAttempts += 1;
|
||
return {
|
||
query: async () => {
|
||
if (systemDbMode === 'down') {
|
||
throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), {
|
||
code: 'ECONNREFUSED',
|
||
});
|
||
}
|
||
return [[systemConfigRow]];
|
||
},
|
||
end: async () => {},
|
||
};
|
||
},
|
||
});
|
||
|
||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const NOT_CONFIGURED = /业务数据库尚未配置/u;
|
||
|
||
test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => {
|
||
await cloudDatabase.close();
|
||
const base = systemDbDialAttempts;
|
||
|
||
await assert.rejects(
|
||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||
NOT_CONFIGURED
|
||
);
|
||
assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置');
|
||
|
||
await assert.rejects(
|
||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||
NOT_CONFIGURED
|
||
);
|
||
assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析');
|
||
|
||
await sleep(220);
|
||
await assert.rejects(
|
||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||
NOT_CONFIGURED
|
||
);
|
||
assert.equal(
|
||
systemDbDialAttempts,
|
||
base + 2,
|
||
'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键'
|
||
);
|
||
});
|
||
|
||
test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => {
|
||
await sleep(220); // 越过上一轮的负缓存 TTL
|
||
systemDbMode = 'recovered';
|
||
const base = systemDbDialAttempts;
|
||
|
||
await assert.rejects(
|
||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||
(error) => {
|
||
// 关键断言:不应再是「尚未配置」——说明配置已成功读回,
|
||
// 失败只是发生在后面连业务库那一层(这里故意指向空端口)。
|
||
assert.doesNotMatch(
|
||
error.message,
|
||
NOT_CONFIGURED,
|
||
'配置读取应已恢复,不该再报「业务数据库尚未配置」'
|
||
);
|
||
return true;
|
||
}
|
||
);
|
||
assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置');
|
||
|
||
// 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库
|
||
const afterRecovery = systemDbDialAttempts;
|
||
await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'));
|
||
assert.equal(
|
||
systemDbDialAttempts,
|
||
afterRecovery,
|
||
'成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)'
|
||
);
|
||
});
|