worker: /reconcile 走 per-route 长超时且绝不故障转移(01a0e595)

POST /reconcile 是后端同步长任务:后端持集群级单飞锁 lock:reconcile(初租 300 s,
期间每 30 s 心跳续租至 90 s,notify.rs:159/172),但网关原先只有全局超时 10 s、
最多 2 次尝试。一次完整对账很容易撑过网关窗口被误判失败,随后故障转移到第二实例,
而第二实例必然立刻返回 409;客户端既拿不到 204 也拿不到 409,只能看到
"all backends failed"。

改动:
- handleFetch 为 /reconcile 单独覆盖超时与尝试次数:超时取 LB_RECONCILE_TIMEOUT_MS
 (默认 320000 ms,大于锁初租 300 s + 心跳余量),maxAttempts=1 绝不故障转移
  (切实例只会撞锁,没有任何收益)
- 其余快路径完全不受影响,仍用全局 LB_REQUEST_TIMEOUT_MS / LB_MAX_ATTEMPTS
- 新增测试:记录 setTimeout 收到的延迟来断言 /reconcile 走 320000 ms,并用 503 响应
  断言它只尝试 1 个 origin;同时断言 /webhook/tg 仍用 5000 ms 且仍故障转移到第二
  实例;并断言 env 覆盖生效而 maxAttempts 恒为 1
- worker README 补 LB_RECONCILE_TIMEOUT_MS 说明与 /reconcile 例外
- deployment.md §6.3.1 删掉「请把 LB_REQUEST_TIMEOUT_MS 调到大于锁租期」的旧 workaround
  建议(现已由 per-route 覆盖解决),§10 worker 故障转移条目补例外,secret 清单补新变量
- 顺手清掉 health.js 注释里已退役的 GATE-READY-DEPS,改为 ARCH-READY-BASELINE

验证:cloudflare-worker node --test 29/29 通过(原 28 + 新增 1);
web config.test.mjs 6/6 通过。无 src/ 改动,Rust 门禁不受影响。
This commit is contained in:
anonsaber 2026-09-28 12:18:04 +08:00
parent cc645020d9
commit 82794c95cf
5 changed files with 71 additions and 12 deletions

View File

@ -3,7 +3,7 @@
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 12 条白名单、 > 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 14 条白名单、
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
@ -36,7 +36,7 @@
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 - 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。 - 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
- method 不符 → **405**。 - method 不符 → **405**。
@ -46,12 +46,13 @@
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。 - 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。 - 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。
- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。 - 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。
- **例外:`POST /reconcile` 走 per-route 覆盖。** 它是后端同步长任务——后端持集群级锁 `lock:reconcile`(初租 300s,期间由心跳续期),全局 10s 超时会把它误判为失败并故障转移到第二实例,而第二实例必然立刻返回 409。所以该路由改为:超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 + 心跳余量),且 `maxAttempts=1` 绝不故障转移。其余快路径保持全局默认。
### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE) ### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE)
- `GET /healthz`(由 Worker 自身承载):按 TTL 缓存(默认 30s)探测各后端 `/healthz`, - `GET /healthz`(由 Worker 自身承载):按 TTL 缓存(默认 30s)探测各后端 `/healthz`,
返回 `{status: ok|down, available, total, backends:[{origin, up, status}]}`; 返回 `{status: ok|down, available, total, backends:[{origin, up, status}]}`;
有 ≥1 后端 up → 200;全部 down → 503。 有 ≥1 后端 up → 200;全部 down → 503。
- **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端 `GATE-READY-DEPS` 由 `/ready`(透传)承担。 - **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端端到端就绪(配置完整性 + Redis 可达 + JMAP session + Telegram getMe,对应 `ARCH-READY-BASELINE`)由 `/ready`(透传)承担。
### 无长连接 / 无密钥日志(§4 红线) ### 无长连接 / 无密钥日志(§4 红线)
- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。 - 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。
@ -63,7 +64,8 @@
|---|---|---| |---|---|---|
| `BACKEND_ORIGINS_JSON` | 是(secret) | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。 | | `BACKEND_ORIGINS_JSON` | 是(secret) | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。 |
| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 | | `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 |
| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。 | | `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。`POST /reconcile` 固定为 `1`。 |
| `LB_RECONCILE_TIMEOUT_MS` | 否 | 仅 `POST /reconcile` 的单 origin 超时覆盖;默认 `320000`(须大于后端锁初租 300s + 心跳余量)。 |
| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 | | `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 |
> secret 通过 `wrangler secret put BACKEND_ORIGINS_JSON` 注入;`wrangler.toml` 里 `vars` 保持为空,不写明文。 > secret 通过 `wrangler secret put BACKEND_ORIGINS_JSON` 注入;`wrangler.toml` 里 `vars` 保持为空,不写明文。

View File

@ -6,7 +6,7 @@
* - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。 * - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。
* - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。 * - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。
* - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE), * - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE),
* 就绪依赖检查仍由后端 `/ready`(GATE-READY-DEPS)承担。 * 就绪依赖检查仍由后端 `/ready`(ARCH-READY-BASELINE)承担。
* *
* 可测性:fetch / 计时器 / 时钟均依赖注入。 * 可测性:fetch / 计时器 / 时钟均依赖注入。
*/ */

View File

@ -28,6 +28,11 @@ import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议) const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议)
// /reconcile 是后端同步长任务:handler 持集群级锁 `lock:reconcile`(初租 300s、30s 心跳续期至 90s,
// notify.rs reconcile 入口)并在同一请求内同步跑 reconcile()。全局 10s 超时会把它误判失败并故障转移到
// 第二实例——第二实例必然立刻撞 409(锁已被占),重试只放大冲突。故仅对此路由覆盖:
// 长超时(默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1,绝不故障转移;其余快路径路由保持全局默认。
const DEFAULT_RECONCILE_TIMEOUT_MS = 320_000;
/** /**
* 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。 * 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。
@ -87,8 +92,19 @@ export async function handleFetch(request, env) {
return text("no backends configured", 503); return text("no backends configured", 503);
} }
const timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); let timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS); let maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS);
// Per-route 覆盖:/reconcile 是后端同步长任务(handler 持集群级锁 lock:reconcile,
// 初租 300s、30s 心跳续期,notify.rs reconcile 入口),并在同一请求内同步跑 reconcile()。
// 全局 10s 超时会把它误判失败并按 maxAttempts=2 故障转移到第二实例——第二实例必然立刻撞
// 409(锁已被占),重试只放大冲突、对 curl/cron 调度器是硬伤。故仅对此路由覆盖:
// 长超时(LB_RECONCILE_TIMEOUT_MS,默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1,
// 绝不故障转移;其余快路径(/webhook/tg 等)保持全局默认 10s/2 不变。
if (path === "/reconcile") {
timeoutMs = intFromEnv(env.LB_RECONCILE_TIMEOUT_MS, DEFAULT_RECONCILE_TIMEOUT_MS);
maxAttempts = 1; // 故障转移无意义:切到另一 origin 必然撞 lock:reconcile 409
}
// 透传转发 + 有界故障转移(仅超时/5xx)。 // 透传转发 + 有界故障转移(仅超时/5xx)。
return forwardWithFailover(origins, request, { return forwardWithFailover(origins, request, {

View File

@ -156,3 +156,42 @@ test("Worker: POST /api/push/register and /api/push/disable are forwarded", asyn
restore(); restore();
} }
}); });
test("Worker: /reconcile 走 per-route 长超时且绝不故障转移;其余快路径保持全局默认", async () => {
const seen = [];
let lastTimeoutMs = null;
const restore = stubFetch(async (url) => {
seen.push(String(url));
return new Response("err", { status: 503 });
});
// 只记录 setTimeout 收到的延迟,不真正触发——否则 abort 会让 fetch 提前失败
const origSetTimeout = globalThis.setTimeout;
const origClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = (_fn, ms) => {
lastTimeoutMs = ms;
return 0;
};
globalThis.clearTimeout = () => {};
try {
await handleFetch(new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }), makeEnv());
assert.equal(seen.length, 1, "POST /reconcile 必须 maxAttempts=1:后端 503 也不换实例");
assert.equal(lastTimeoutMs, 320_000, "POST /reconcile 默认超时 320000ms");
seen.length = 0;
await handleFetch(
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),
makeEnv({ LB_RECONCILE_TIMEOUT_MS: "60000" }),
);
assert.equal(seen.length, 1, "POST /reconcile 的 maxAttempts=1 不受 env 覆盖影响");
assert.equal(lastTimeoutMs, 60_000, "POST /reconcile 必须遵守 LB_RECONCILE_TIMEOUT_MS");
seen.length = 0;
await handleFetch(new Request("https://lb.example/webhook/tg", { method: "POST", body: "{}" }), makeEnv());
assert.equal(seen.length, 2, "POST /webhook/tg 仍要故障转移到第二实例");
assert.equal(lastTimeoutMs, 5_000, "其余路由仍须使用全局 LB_REQUEST_TIMEOUT_MS");
} finally {
globalThis.setTimeout = origSetTimeout;
globalThis.clearTimeout = origClearTimeout;
restore();
}
});

View File

@ -275,10 +275,11 @@ spec:
兜底),不会重复通知。所以对 `503` 可以放心重试。 兜底),不会重复通知。所以对 `503` 可以放心重试。
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。
- 走 Worker 转发时注意:网关默认单请求超时 10 s、最多 2 次尝试(`index.js:29-30`),而 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
单飞锁租期是 300 s(对账持锁心跳每 30 s 续租至 90 s,`notify.rs:159`/`172`)。一次完整 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
对账可能超出网关转发窗口——若确实经 Worker 调度,请把 `LB_REQUEST_TIMEOUT_MS` 调到 `notify.rs:159`/`172`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
大于锁租期,或让调度器直接打到后端 origin。 `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
(默认 `2`),不受影响。
### 6.4 可靠性策略(Reliability) ### 6.4 可靠性策略(Reliability)
@ -448,7 +449,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:40-55` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 条不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)——**这 4 条都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:40-55` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 条不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)——**这 4 条都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。 - **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。
- **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。 - **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。
@ -494,6 +495,7 @@ npx wrangler secret put BACKEND_ORIGINS_JSON # JSON 数组,见下方示例
# 可选(带默认值) # 可选(带默认值)
npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 默认 10000ms npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 默认 10000ms
npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移) npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移)
npx wrangler secret put LB_RECONCILE_TIMEOUT_MS # 默认 320000ms,仅 POST /reconcile 生效
npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms
``` ```