From 6bb57801ac1c6ab412b0187a58d3dddeabeb0226 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Wed, 30 Sep 2026 23:08:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(lb):=20POST=20/worker=20=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20safelist=EF=BC=8C=E8=B5=B0=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=95=BF=E8=B6=85=E6=97=B6=E4=B8=94=E4=B8=8D=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E8=BD=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部 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)。 --- cloudflare-worker/src/backends.js | 4 ++- cloudflare-worker/src/index.js | 13 +++++++- cloudflare-worker/test/backends.test.js | 6 ++-- cloudflare-worker/test/index.test.js | 41 +++++++++++++++++++++++++ cloudflare-worker/wrangler.toml | 11 +++++-- 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index ae4a687..1b5883b 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -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", ]); diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 1be4c31..c93bed4 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -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)。 diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js index a688c7b..68750e9 100644 --- a/cloudflare-worker/test/backends.test.js +++ b/cloudflare-worker/test/backends.test.js @@ -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}`); diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index e7aef6b..0ed4c9f 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -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(); + } +}); diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index a698194..b4c4366 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -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"