cangku/backend/test/cloudDatabaseConfigCache.test.js
yanhui 38fa3f2127 fix: 隧道断线自愈 + 修复配置负缓存永久固化
远端 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。
2026-09-22 19:32:40 +08:00

127 lines
4.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 配置负缓存 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 反复淘汰(否则每个请求都要多打一次系统库)'
);
});