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"