feat(lb): POST /worker 加入路由 safelist,走独立长超时且不故障转移

外部 cron 触发排空队列时,/worker 走 Worker 域名会被 404 route not forwarded
挡掉,只能直连后端 origin;而 /reconcile 早就能走 Worker。cron 脚本因此被迫
维护两个域名。现把 /worker 与 /reconcile 成对放进 safelist(17 -> 18 条),
cron 只需一个平台域名。

同时给它配独立超时:全局 LB_REQUEST_TIMEOUT_MS 默认 10s 会把一次正常的排空
误判成失败,而 maxAttempts=2 切到第二实例毫无意义——第二个实例只会重复排
同一批消息。故新增 LB_WORKER_TIMEOUT_MS(默认 300000ms,等于后端单条事件
下界 state.rs SINGLE_EVENT_CEILING_FLOOR_MS),并强制 maxAttempts=1。

- SAFE_ROUTES 加 "/worker";ROUTE_METHODS 加 "/worker": ["POST"](GET => 405)
- 新增 DEFAULT_WORKER_TIMEOUT_MS = 300_000 与 per-route 覆盖分支
- LB_VERSION 2026.10.2 -> 2026.10.3(改 LB 逻辑须 bump)
- test/index.test.js 新增回归:/worker maxAttempts=1、默认 300s、env 覆盖、
  GET /worker => 405 且不打到后端
- test/backends.test.js safelist 用例补 /worker 与 /api/business-config/preflight

node --test:37 通过 / 0 失败(index 14 / backends 6 / health 5 / lb 12)。
This commit is contained in:
anonsaber 2026-09-30 23:08:41 +08:00
parent fae37b35bc
commit 6bb57801ac
5 changed files with 68 additions and 7 deletions

View File

@ -6,7 +6,8 @@
/** 允许透传的入站路由 safelist(C-LB-SINGLE-REG-URL):未知路径 → 404。
* `/healthz` 透传到源站(源站自己的健康检查);LB 自己的聚合探针在 `/healthz-worker`,不在列。
* SPA 静态资源为公开 GET;所有管理 API 仍由后端校验 Redis bootstrap 凭据或 admin session。 */
* SPA 静态资源为公开 GET;所有管理 API 仍由后端校验 Redis bootstrap 凭据或 admin session。
* `/reconcile` 与 `/worker` 都需要外部 cron 触发且都带 Bearer token,成对出现在白名单里。 */
export const SAFE_ROUTES = Object.freeze([
"/",
"/assets/config.js",
@ -23,6 +24,7 @@ export const SAFE_ROUTES = Object.freeze([
"/api/push/register",
"/api/push/disable",
"/reconcile",
"/worker",
"/healthz",
"/ready",
]);

View File

@ -18,7 +18,8 @@
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
* - GET /healthz → 透传到源站(源站自己的健康检查)
* - GET /healthz-worker → LB 聚合健康(MOD-HEALTH-AGG),响应体带 LB_VERSION
* - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable|/reconcile、GET /ready → 透传 + 有界故障转移
* - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable、GET /ready → 透传 + 有界故障转移
* - POST /reconcile|/worker → 外部 cron 触发的同步长任务,长超时 + 单发不故障转移
* - 其它 → 404 / 405
*
* 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。
@ -35,6 +36,12 @@ const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议)
// 第二实例——第二实例必然立刻撞 409(锁已被占),重试只放大冲突。故仅对此路由覆盖:
// 长超时(默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1,绝不故障转移;其余快路径路由保持全局默认。
const DEFAULT_RECONCILE_TIMEOUT_MS = 320_000;
// /worker 同样是外部 cron 触发的同步长任务:一次最多排空 batch 条(默认 10,硬上限 10),
// 每条消息的出站调用会重试 max_retries 次,最坏墙钟远超全局 10s——超时会把一次正常的
// 排空误判成失败并按 maxAttempts=2 切到第二实例,第二个实例只会重复排同一批消息。
// 取后端自己的单条事件下界(state.rs SINGLE_EVENT_CEILING_FLOOR_MS = 300s)做默认值:
// 单条慢消息永远装得下;batch 拉大时需要相应上调。
const DEFAULT_WORKER_TIMEOUT_MS = 300_000;
/**
* 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。
@ -60,6 +67,7 @@ const ROUTE_METHODS = Object.freeze({
"/api/push/register": ["POST"],
"/api/push/disable": ["POST"],
"/reconcile": ["POST"],
"/worker": ["POST"],
"/healthz": ["GET"],
"/ready": ["GET"],
});
@ -114,6 +122,9 @@ export async function handleFetch(request, env) {
if (path === "/reconcile") {
timeoutMs = intFromEnv(env.LB_RECONCILE_TIMEOUT_MS, DEFAULT_RECONCILE_TIMEOUT_MS);
maxAttempts = 1; // 故障转移无意义:切到另一 origin 必然撞 lock:reconcile 409
} else if (path === "/worker") {
timeoutMs = intFromEnv(env.LB_WORKER_TIMEOUT_MS, DEFAULT_WORKER_TIMEOUT_MS);
maxAttempts = 1; // 故障转移无意义:切到另一 origin 只会重复排同一批消息
}
// 透传转发 + 有界故障转移(仅超时/5xx)。

View File

@ -42,11 +42,13 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
assert.throws(() => parseBackendOrigins("[]"), /non-empty/);
});
test("route safelist forwards /healthz and blocks unknown paths (C-LB-SINGLE-REG-URL)", () => {
test("route safelist forwards /healthz, /reconcile and /worker, blocks unknown paths (C-LB-SINGLE-REG-URL)", () => {
for (const r of [
"/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
"/worker",
"/api/push/register", "/api/push/disable",
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
"/api/config", "/api/business-config", "/api/business-config/preflight",
"/api/admin/session", "/api/admin/session/revoke",
"/api/enabled", "/healthz",
]) {
assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`);

View File

@ -291,3 +291,44 @@ test("Worker: /reconcile 走 per-route 长超时且绝不故障转移;其余
restore();
}
});
test("Worker: POST /worker 在白名单内,走 per-route 长超时且绝不故障转移;GET /worker => 405", async () => {
// /worker 和 /reconcile 一样只由外部 cron 触发(scripts/cron-drain.sh)。全局 10s 超时会把
// 一次正常的排空误判成失败并切到第二实例,而第二个实例只会重复排同一批消息。
const seen = [];
let lastTimeoutMs = null;
const restore = stubFetch(async (url) => {
seen.push(String(url));
return new Response("err", { status: 503 });
});
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/worker", { method: "POST", body: "{}" }), makeEnv());
assert.equal(seen.length, 1, "POST /worker 必须 maxAttempts=1:后端 503 也不换实例");
assert.equal(lastTimeoutMs, 300_000, "POST /worker 默认超时 300000ms");
seen.length = 0;
await handleFetch(
new Request("https://lb.example/worker", { method: "POST", body: "{}" }),
makeEnv({ LB_WORKER_TIMEOUT_MS: "60000" }),
);
assert.equal(seen.length, 1, "POST /worker 的 maxAttempts=1 不受 env 覆盖影响");
assert.equal(lastTimeoutMs, 60_000, "POST /worker 必须遵守 LB_WORKER_TIMEOUT_MS");
seen.length = 0;
const res = await handleFetch(new Request("https://lb.example/worker", { method: "GET" }), makeEnv());
assert.equal(res.status, 405, "GET /worker 必须 405,不能被误透传");
assert.equal(res.headers.get("allow"), "POST");
assert.equal(seen.length, 0, "405 必须被 LB 拦住,不打到后端");
} finally {
globalThis.setTimeout = origSetTimeout;
globalThis.clearTimeout = origClearTimeout;
restore();
}
});

View File

@ -36,14 +36,19 @@ compatibility_flags = ["nodejs_compat_v2"]
# LB_REQUEST_TIMEOUT_MS 可选,单 origin 超时(默认 10000,建议 > 最坏 cold start)
# LB_MAX_ATTEMPTS 可选,每请求最多 origin 尝试次数(默认 2:首次 + 1 次故障转移,
# 对应 deployment.md §10.4「有界重试其它实例(建议 1 次)」)
# LB_VERSION 版本标识,回显在 GET /healthz 的 JSON 里;用于确认线上是哪次部署
# LB_RECONCILE_TIMEOUT_MS 可选,仅 POST /reconcile 的超时覆盖(默认 320000)
# LB_WORKER_TIMEOUT_MS 可选,仅 POST /worker 的超时覆盖(默认 300000)
# 两条都是外部 cron 触发的同步长任务,且都强制 maxAttempts=1
# 不故障转移(第二实例对 /reconcile 必然撞锁 409、对 /worker 只会
# 重复排同一批消息),所以各配一个更长的超时,不能用全局 10s
# LB_VERSION 版本标识,回显在 GET /healthz-worker 的 JSON 里;用于确认线上是哪次部署
# 在回答(改 LB 逻辑的提交同时 bump 它)
#
# ⚠️ 明文例外的边界:[vars] 里的 origin 只能是**公开可达的 https origin**(当前就是)。
# 一旦出现内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名,立刻从 [vars] 删掉,
# 改用 `npx wrangler secret put BACKEND_ORIGINS_JSON`(git 历史不可逆,删掉不等于没进过库)。
# 其余三个 LB_* 调参保持可调、不写死,用 dashboard 的 Add Variable 覆盖默认值即可;
# 其余五个 LB_* 调参保持可调、不写死,用 dashboard 的 Add Variable 覆盖默认值即可;
# LB_VERSION 是例外——它是版本标识,必须跟代码同版本、可 diff,写进 dashboard 反而容易漂。
[vars]
BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club"]'
LB_VERSION = "2026.10.2"
LB_VERSION = "2026.10.3"