diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 3d0fe77..7b641b7 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -3,7 +3,7 @@ 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 -> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 12 条白名单、 +> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 14 条白名单、 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 @@ -36,7 +36,7 @@ ### 路由 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 的鉴权仍由后端执行。 -- 只透传 `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 沦为后端任意路径的跳板)。 - method 不符 → **405**。 @@ -46,12 +46,13 @@ - 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。 - 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。 - 健康缓存(`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) - `GET /healthz`(由 Worker 自身承载):按 TTL 缓存(默认 30s)探测各后端 `/healthz`, 返回 `{status: ok|down, available, total, backends:[{origin, up, status}]}`; 有 ≥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 红线) - 不使用 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/路径。 | | `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`。 | > secret 通过 `wrangler secret put BACKEND_ORIGINS_JSON` 注入;`wrangler.toml` 里 `vars` 保持为空,不写明文。 diff --git a/cloudflare-worker/src/health.js b/cloudflare-worker/src/health.js index 7ed53f1..16a10be 100644 --- a/cloudflare-worker/src/health.js +++ b/cloudflare-worker/src/health.js @@ -6,7 +6,7 @@ * - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。 * - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。 * - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE), - * 就绪依赖检查仍由后端 `/ready`(GATE-READY-DEPS)承担。 + * 就绪依赖检查仍由后端 `/ready`(ARCH-READY-BASELINE)承担。 * * 可测性:fetch / 计时器 / 时钟均依赖注入。 */ diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 1f55e47..b54bb43 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -28,6 +28,11 @@ import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health const DEFAULT_TIMEOUT_MS = 10_000; 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 内跨请求复用)。 @@ -87,8 +92,19 @@ export async function handleFetch(request, env) { return text("no backends configured", 503); } - const timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); - const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS); + let timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); + 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)。 return forwardWithFailover(origins, request, { diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index d229097..9ea585e 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -156,3 +156,42 @@ test("Worker: POST /api/push/register and /api/push/disable are forwarded", asyn 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(); + } +}); diff --git a/docs/deployment.md b/docs/deployment.md index 1774676..a5ef14b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -275,10 +275,11 @@ spec: 兜底),不会重复通知。所以对 `503` 可以放心重试。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 -- 走 Worker 转发时注意:网关默认单请求超时 10 s、最多 2 次尝试(`index.js:29-30`),而 - 单飞锁租期是 300 s(对账持锁心跳每 30 s 续租至 90 s,`notify.rs:159`/`172`)。一次完整 - 对账可能超出网关转发窗口——若确实经 Worker 调度,请把 `LB_REQUEST_TIMEOUT_MS` 调到 - 大于锁租期,或让调度器直接打到后端 origin。 +- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 + `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 + `notify.rs:159`/`172`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 + `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` + (默认 `2`),不受影响。 ### 6.4 可靠性策略(Reliability) @@ -448,7 +449,7 @@ Stalwart PushSub ────┼─▶ https://lb..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`)。 - **健康聚合(`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 配置时退化为确定性。 - **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。 - **超时预算**(`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_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移) +npx wrangler secret put LB_RECONCILE_TIMEOUT_MS # 默认 320000ms,仅 POST /reconcile 生效 npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms ```