diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 70946ab..efab58d 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -31,10 +31,10 @@ 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, +// /reconcile 是后端同步长任务:handler 持集群级锁 `lock:reconcile`(初租 60s、10s 心跳续期至 30s, // notify.rs reconcile 入口)并在同一请求内同步跑 reconcile()。全局 10s 超时会把它误判失败并故障转移到 // 第二实例——第二实例必然立刻撞 409(锁已被占),重试只放大冲突。故仅对此路由覆盖: -// 长超时(默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1,绝不故障转移;其余快路径路由保持全局默认。 +// 长超时(默认 320s > 最坏对账耗时 + 余量)+ maxAttempts=1,绝不故障转移;其余快路径路由保持全局默认。 const DEFAULT_RECONCILE_TIMEOUT_MS = 320_000; // /worker 同样是外部 cron 触发的同步长任务:一次最多排空 batch 条(默认 10,硬上限 10), // 每条消息的出站调用会重试 max_retries 次,最坏墙钟远超全局 10s——超时会把一次正常的 @@ -120,10 +120,10 @@ export async function handleFetch(request, env) { let maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS); // Per-route 覆盖:/reconcile 是后端同步长任务(handler 持集群级锁 lock:reconcile, - // 初租 300s、30s 心跳续期,notify.rs reconcile 入口),并在同一请求内同步跑 reconcile()。 + // 初租 60s、10s 心跳续期,notify.rs reconcile 入口),并在同一请求内同步跑 reconcile()。 // 全局 10s 超时会把它误判失败并按 maxAttempts=2 故障转移到第二实例——第二实例必然立刻撞 // 409(锁已被占),重试只放大冲突、对 curl/cron 调度器是硬伤。故仅对此路由覆盖: - // 长超时(LB_RECONCILE_TIMEOUT_MS,默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1, + // 长超时(LB_RECONCILE_TIMEOUT_MS,默认 320s > 最坏对账耗时 + 余量)+ maxAttempts=1, // 绝不故障转移;其余快路径(/webhook/tg 等)保持全局默认 10s/2 不变。 if (path === "/reconcile") { timeoutMs = intFromEnv(env.LB_RECONCILE_TIMEOUT_MS, DEFAULT_RECONCILE_TIMEOUT_MS); diff --git a/docs/charter.md b/docs/charter.md index e92e648..ce4abed 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -236,7 +236,7 @@ Answer only one question per document; **tables, lists and values must not be du | `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI authorization period and Redis short-term TTL (authorization validity period, reask after expiration) | Requirements | | `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | Single account; multiple accounts = multiple bot instances | Requirements | | `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 Comments | `PushSubscription/set` create does not have the `types` parameter in jmap-client 0.4.2; the subscription id must be narrowed to `Email` + `EmailDelivery` by `push_subscription_update_types` before being exposed to the outside world | Requirements | -| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP reconciliation cursor is only advanced after all paging events are successfully enqueued (XADD); a single reconciliation is guaranteed to fly solo (TTL) by Redis SET NX EX lock `lock:reconcile` 300s, owner token is renewed for 90s, only the holder can renew/release); the processing end then goes through `claim_dedup` (SET NX EX, 86400s) to ensure that the same flow message is not delivered repeatedly | Requirements | +| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP reconciliation cursor is only advanced after all paging events are successfully enqueued (XADD); a single reconciliation is guaranteed to fly solo (TTL) by Redis SET NX EX lock `lock:reconcile` 60s, owner token is renewed for 30s, only the holder can renew/release); the processing end then goes through `claim_dedup` (SET NX EX, 86400s) to ensure that the same flow message is not delivered repeatedly | Requirements | | `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | Notification receipt time is configured by business `timezone` (IANA, default `Asia/Shanghai`) is rendered as `%Y-%m-%d %H:%M`; only 16 non-daylight saving time zones are accepted, no match is returned 422, no time zone library inference | Requirements | | `NFR-NOTIFY-SLA` | README.md §6.3 | Notification availability ≥99.9%, minor latency allowed | Non-functional | | `NFR-RECONCILE-INTERVAL` | README.md §3.10 | External cron reconciliation interval 5–10 minutes | Non-functional | diff --git a/docs/charter.zh-CN.md b/docs/charter.zh-CN.md index aa0a64c..7654f11 100644 --- a/docs/charter.zh-CN.md +++ b/docs/charter.zh-CN.md @@ -236,7 +236,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查 | `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 | | `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | | `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | -| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | +| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 60s,owner token 续期 30s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | | `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 | | `NFR-NOTIFY-SLA` | README.md §6.3 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | | `NFR-RECONCILE-INTERVAL` | README.md §3.10 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | diff --git a/docs/design.md b/docs/design.md index 19de836..707aea9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -642,7 +642,7 @@ Will be modified: `push:subscription:{id}` was previously marked as 7d, and `pus Both are wrong, and the real 360s solo lock `lock:push-register:{sha256(callback_url)}` was missing the entire line. has now converged to Single authority table, this section only retains four "whys" that affect design judgment: -- **Reconciliation lock TTL 300s, deliberately larger than the upper limit of 120s for a single page**, to avoid lock expiration during the lock period, causing the same account to enter reconciliation repeatedly +- **Reconciliation lock TTL 60s, with heartbeat renewal every 10s to 30s during active processing**, ensuring a crashed process releases the lock within 60s instead of blocking reconciliation (`REQ-RECONCILE-IDEMPOTENCY`, `SAF-RECONCILE-LOCK`). - **push registration solo lock TTL 360s, deliberately larger than the upper limit of 300s for a single outbound request**, otherwise a slow registration will cause repeated requests (`SAF-AUTH-JMAP-PUSH`). diff --git a/docs/design.zh-CN.md b/docs/design.zh-CN.md index b9f1ade..b12e82d 100644 --- a/docs/design.zh-CN.md +++ b/docs/design.zh-CN.md @@ -642,7 +642,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 两处都错,而真正的 360s 单飞锁 `lock:push-register:{sha256(callback_url)}` 当时整行缺失。现已收敛到 单一权威表,本节只保留影响设计判断的四条"为什么": -- **对账锁 TTL 300s,刻意大于单页 120s 上限**,避免持锁期间锁过期导致同一账号重复进入对账 +- **对账锁 TTL 60s,活跃处理期间每 10s 心跳续期至 30s**,确保崩溃进程在 60s 内释放锁、不阻塞对账 (`REQ-RECONCILE-IDEMPOTENCY`、`SAF-RECONCILE-LOCK`)。 - **push 注册单飞锁 TTL 360s,刻意大于单条出站请求上限 300s**,否则一次慢注册会放进重复请求 (`SAF-AUTH-JMAP-PUSH`)。 diff --git a/docs/opengaps.md b/docs/opengaps.md index 8598768..0ec48bc 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -45,8 +45,9 @@ Contract baseline (see `docs/reference.md` §1 for the complete semantics of key | constant | value (seconds) | key | | --- | --- | --- | -| `ttl::RECONCILE_LOCK_SECONDS` | 300 | `lock:reconcile` | -| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 90 | `lock:reconcile` (heartbeat renewal) | +| `ttl::RECONCILE_LOCK_SECONDS` | 60 | `lock:reconcile` | +| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 30 | `lock:reconcile` (heartbeat renewal) | +| `ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS` | 10 | heartbeat sleep interval | | `ttl::PUSH_REGISTER_LOCK_SECONDS` | 360 | `lock:push-register:{sha256(callback_url)}` | | `ttl::PUSH_VERIFY_LIMIT_SECONDS` | 30 | `ratelimit:push-verify:{subscription_id}` | | `ttl::DEDUP_TG_SECONDS` | 86_400 | `dedup:tg:{update_id}` | @@ -103,8 +104,8 @@ Single accounts are fixed by requirement (`REQ-SINGLE-ACCOUNT`) and never enter **Minimum changes if necessary in the future** (The current situation has been verified in this round and will be used directly when disassembly is needed) -- `state:jmap:since` is a **single global cursor**, no account dimension (read `src/state.rs:792`, write `src/state.rs:800`) - both accounts will overwrite each other's cursors, which is the first place to move. -- `lock:reconcile` is a **single global solo lock** (Redis implementation `src/state.rs:1130`) - the reconciliation of two accounts will be serialized by the same lock. +- `state:jmap:since` is a **single global cursor**, no account dimension (read `src/state.rs:801`, write `src/state.rs:809`) - both accounts will overwrite each other's cursors, which is the first place to move. +- `lock:reconcile` is a **single global solo lock** (Redis implementation `src/state.rs:1139`) - the reconciliation of two accounts will be serialized by the same lock. - `dedup:jmap:{account_id}:{email_id}` **Already has the account segment** (`docs/reference.md` §1), the key shape does not need to be changed. - The `ACCOUNT_ID` environment variable already exists and has been verified (leave it blank to take the session main account, and an error will be reported if the out-of-bounds value is `src/domain/jmap/client.rs:485`). The business configuration layer can also override `account_id` (`unwrap_or_else` of `src/config.rs:465` falls back). The configuration interface is ready, all that is missing is the sub-dimension status within the instance. diff --git a/docs/opengaps.zh-CN.md b/docs/opengaps.zh-CN.md index 38403e3..ffd393d 100644 --- a/docs/opengaps.zh-CN.md +++ b/docs/opengaps.zh-CN.md @@ -45,8 +45,9 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1), | 常量 | 值(秒) | 键 | | --- | --- | --- | -| `ttl::RECONCILE_LOCK_SECONDS` | 300 | `lock:reconcile` | -| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 90 | `lock:reconcile`(心跳续租) | +| `ttl::RECONCILE_LOCK_SECONDS` | 60 | `lock:reconcile` | +| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 30 | `lock:reconcile`(心跳续租) | +| `ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS` | 10 | 心跳 sleep 间隔 | | `ttl::PUSH_REGISTER_LOCK_SECONDS` | 360 | `lock:push-register:{sha256(callback_url)}` | | `ttl::PUSH_VERIFY_LIMIT_SECONDS` | 30 | `ratelimit:push-verify:{subscription_id}` | | `ttl::DEDUP_TG_SECONDS` | 86_400 | `dedup:tg:{update_id}` | @@ -103,8 +104,8 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1), **将来若真要做的最小改动面**(本轮核实过现状,供拆需求时直接用) -- `state:jmap:since` 是**单一全局游标**,无账户维度(读 `src/state.rs:792`、写 `src/state.rs:800`)——两个账户会互相覆盖游标,这是第一个要动的地方。 -- `lock:reconcile` 是**单一全局单飞锁**(Redis 实现 `src/state.rs:1130`)——两个账户的对账会被同一把锁串行化。 +- `state:jmap:since` 是**单一全局游标**,无账户维度(读 `src/state.rs:801`、写 `src/state.rs:809`)——两个账户会互相覆盖游标,这是第一个要动的地方。 +- `lock:reconcile` 是**单一全局单飞锁**(Redis 实现 `src/state.rs:1139`)——两个账户的对账会被同一把锁串行化。 - `dedup:jmap:{account_id}:{email_id}` **已经带账户段**(`docs/reference.md` §1),键形无需改。 - `ACCOUNT_ID` 环境变量已存在且经校验(留空取 session 主账户,越界值在 `src/domain/jmap/client.rs:485` 报错),业务配置层也能覆盖 `account_id`(`src/config.rs:465` 的 `unwrap_or_else` 回落)。配置面已就绪,缺的只是实例内的分维度状态。 diff --git a/docs/reference.md b/docs/reference.md index 3fb1ec9..2638c09 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -125,7 +125,7 @@ re-baseline on the next restart. | Key | TTL | Writer | Reader | |---|---|---|---| -| `lock:reconcile` | 300 | `acquire_lock` in `reconcile` | `renew_lock` from the heartbeat task (90 s) | +| `lock:reconcile` | 60 | `acquire_lock` in `reconcile` | `renew_lock` from the heartbeat task (30 s) | | `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` in `register_push` | `release_lock` on every exit path of `register_push` | **Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest @@ -526,7 +526,7 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in | `RECONCILE_BUDGET` | 20 s | `worker.rs` | | `CHANGE_WINDOW_CAP` | 4_096 | `worker.rs` | | `RECONCILE_INITIAL_CHANGES` | 100 | `notify.rs` | -| Lock TTL / heartbeat | 300 s / 90 s | `reconcile` and its heartbeat task | +| Lock TTL / heartbeat | 60 s / 30 s | `reconcile` and its heartbeat task | `initial_changes` is a function parameter, not a constant: it sizes only the first `/changes` call. The replay phase may widen the window afterwards, up to diff --git a/docs/reference.zh-CN.md b/docs/reference.zh-CN.md index ee30472..b536e6f 100644 --- a/docs/reference.zh-CN.md +++ b/docs/reference.zh-CN.md @@ -124,7 +124,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 | 键 | TTL | 写入方 | 读取方 | |---|---|---|---| -| `lock:reconcile` | 300 | `reconcile` 中的 `acquire_lock` |心跳任务中的 `renew_lock`(90 秒) | +| `lock:reconcile` | 60 | `reconcile` 中的 `acquire_lock` |心跳任务中的 `renew_lock`(30 秒) | | `lock:push-register:{sha256(callback_url)}` | 360 | `register_push` 中的 `acquire_lock` | `register_push` 每个退出路径上的 `release_lock` **为什么注册锁是 360 秒而不是 300 秒。** 锁的寿命必须是最长的 @@ -515,7 +515,7 @@ build 没有 IANA tz 数据库(“chrono-tz”不可用),因此每个区 | `协调预算` | 20 秒 | `worker.rs` | | `CHANGE_WINDOW_CAP` | 4_096 | 4_096 `worker.rs` | | `协调初始更改` | 100 | 100 `notify.rs` | -|锁定TTL/心跳| 300 秒 / 90 秒 | `reconcile` 及其心跳任务 | +|锁定TTL/心跳| 60 秒 / 30 秒 | `reconcile` 及其心跳任务 | `initial_changes` 是一个函数参数,而不是一个常量:它只调整 第一个“/changes”调用。重播阶段之后可能会扩大窗口,最多 diff --git a/docs/retired.md b/docs/retired.md index 1488075..6cca567 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -77,10 +77,10 @@ These names never exist in the code. Some of them appear in the directory tree o |---|---|---|---| | Directory scheme that splits Telegram access into four files "module entry/command parsing/session state/reply rendering" | Fictitious directory scheme | Drawn in the target directory tree, never created | Command parsing is in `parse_intent` (`src/worker.rs`); the session/authorization state is Redis TTL (the AI authorization state section of `docs/reference.md`); the rendering function is in `src/channel.rs` | | `delivery:pending:{stream}` | Fictional key | Confused with Redis Streams' pending-entries list (PEL) - PEL is maintained internally by Redis and is not a writable key | Real key: `delivery:inflight:{stream}:{id}` (EX 60) and `delivery:committed:{stream}:{id}` (EX 604_800), see Delivery pipeline keys section of `docs/reference.md` | -| `check_config_reload` | Fictional function | Uncommitted name in draft, never made it into code | Hot updated to `refresh_business_config` (`src/notify.rs:1130`) | -| `push:registration:{sha256(callback_url)}` was once written as "360s registration solo lock" | Fact mislabeling | The key is 7d callback → subscription ID mapping | The real 360s solo lock is `lock:push-register:{sha256(callback_url)}` (`src/notify.rs:1481-1483`) | +| `check_config_reload` | Fictional function | Uncommitted name in draft, never made it into code | Hot updated to `refresh_business_config` (`src/notify.rs:1133`) | +| `push:registration:{sha256(callback_url)}` was once written as "360s registration solo lock" | Fact mislabeling | The key is 7d callback → subscription ID mapping | The real 360s solo lock is `lock:push-register:{sha256(callback_url)}` (`src/notify.rs:1484-1485`) | | `MESSAGWEAVE_DOMAIN` was once written as "Worker whitelist is invalid when production is not configured" | Fictional assertion (unsubmitted draft/appeared in discussion, not documented) | This environment variable has zero hits in all positions; Worker whitelist is **unconditional fail-closed**: unknown path 404, method does not match 405, backend is missing or parsing failure 503 (`cloudflare-worker/src/index.js:77-92`) | No need to configure switches, the whitelist will always take effect | -| `read_batch` / `retry_or_dlq` "Redis may still return `Ok(())` when an error occurs, so the consumption loop will not exit due to a single failure" | Fictional assertion | The consumption entry is HTTP handler `worker` (`notify.rs:390`), **not a background loop**: full position `src/` zero hit `select!`, no signal processing, no resident worker process. `read_batch` (`state.rs:506`) throws the Redis error as it is (`state.rs:546`) via `?`, and the only discarded result is the `BUSYGROUP` idempotent protection of XGROUP `CREATE` (`state.rs:514-522`); `retry_or_dlq` is also thrown up via `?`. The caller returns `503 service_unavailable` + `retryable=true` for every `Err`, so the semantic premise of "the loop does not exit due to a single failure" itself is not true | None - the gap does not exist; `docs/opengaps.md` (original name `docs/roadmap.md`) "Code Gap" 4 → 3, `docs/design.md` "Known Boundary" same sentence has been deleted | +| `read_batch` / `retry_or_dlq` "Redis may still return `Ok(())` when an error occurs, so the consumption loop will not exit due to a single failure" | Fictional assertion | The consumption entry is HTTP handler `worker` (`notify.rs:393`), **not a background loop**: full position `src/` zero hit `select!`, no signal processing, no resident worker process. `read_batch` (`state.rs:515`) throws the Redis error as it is (`state.rs:555`) via `?`, and the only discarded result is the `BUSYGROUP` idempotent protection of XGROUP `CREATE` (`state.rs:523-531`); `retry_or_dlq` is also thrown up via `?`. The caller returns `503 service_unavailable` + `retryable=true` for every `Err`, so the semantic premise of "the loop does not exit due to a single failure" itself is not true | None - the gap does not exist; `docs/opengaps.md` (original name `docs/roadmap.md`) "Code Gap" 4 → 3, `docs/design.md` "Known Boundary" same sentence has been deleted | --- @@ -89,7 +89,7 @@ These names never exist in the code. Some of them appear in the directory tree o | Build the channel layer into a directory module (including module entry file) | Fictitious directory | The channel layer is a file called `src/channel.rs` | None | | `notify::push_handler` / `notify::worker` / `notify::reconcile` as module paths | Fictitious module paths | These are free functions within `src/notify.rs` (`jmap_push` / `worker` / `reconcile`), not module paths | None | | `mod_dedup` / `mod_streams` / `mod_sincestate` | Fictional module name | `state.rs` / `notify.rs` are tile files, no submodules; deduplication and Streams logic exist as free functions | None | -| `PushVerification` type | Fictitious type | Undefined; `register_push` (`notify.rs:1468`) handles callback URL and verification code writeback inline | None | +| `PushVerification` type | Fictitious type | Undefined; `register_push` (`notify.rs:1471`) handles callback URL and verification code writeback inline | None | | `CancellationToken` | fictitious type | unused; no graceful shutdown, no signal handling (`src/` zero hits `tokio::signal` / `ctrl_c`) | None | | `Preview` type / 4000 character long email protection / `[Continue to view original text]` button / `/llm-fallback` button | Fictional type and UI | All not implemented; after authorization, the full text will be handed over to LLM, and if it fails, the first 300 characters will be returned, without any truncation mark or button | None | | `LlmErr` (5 variants) | Fictional enum | Real is `AiError`, only 3 variants (`InvalidEndpoint` / `Request` / `Response`) | None | diff --git a/docs/retired.zh-CN.md b/docs/retired.zh-CN.md index c9b5ed5..183643a 100644 --- a/docs/retired.zh-CN.md +++ b/docs/retired.zh-CN.md @@ -77,10 +77,10 @@ |---|---|---|---| | 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | -| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1130`) | -| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1481-1483`) | +| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1133`) | +| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1484-1485`) | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | -| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:390`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:506`)经 `?` 把 Redis 错误原样上抛(`state.rs:546`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:514-522`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | +| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:393`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:515`)经 `?` 把 Redis 错误原样上抛(`state.rs:555`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:523-531`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | --- @@ -89,7 +89,7 @@ | 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | -| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1468`)内联处理回调 URL 与验证码回写 | 无 | +| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1471`)内联处理回调 URL 与验证码回写 | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | diff --git a/src/notify.rs b/src/notify.rs index c5eed26..1321bbd 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -300,7 +300,10 @@ async fn reconcile(State(app): State, headers: HeaderMap) -> Response let heartbeat_lease_lost = Arc::clone(&lease_lost); let heartbeat = tokio::spawn(async move { loop { - tokio::time::sleep(std::time::Duration::from_secs(30)).await; + tokio::time::sleep(std::time::Duration::from_secs( + ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS, + )) + .await; match heartbeat_state .renew_lock( "lock:reconcile", diff --git a/src/state.rs b/src/state.rs index d8fa6bf..8f0bef6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -32,10 +32,19 @@ pub(crate) mod ttl { pub const WEEK: u64 = 7 * DAY; pub const YEAR: u64 = 365 * DAY; - /// Initial hold on the reconcile job. - pub const RECONCILE_LOCK_SECONDS: u64 = 300; - /// Heartbeat renewal. Must stay well under `RECONCILE_LOCK_SECONDS`. - pub const RECONCILE_HEARTBEAT_SECONDS: u64 = 90; + /// Initial hold on the reconcile job. Kept short: if the process crashes + /// before the first heartbeat fires, the lock expires in ≤60 s instead of + /// the previous 300 s. The heartbeat renews to `RECONCILE_HEARTBEAT_SECONDS` + /// well before this expires. + pub const RECONCILE_LOCK_SECONDS: u64 = 60; + /// Heartbeat renewal TTL. Must stay well under `RECONCILE_LOCK_SECONDS` and + /// be at least `RECONCILE_HEARTBEAT_INTERVAL_SECONDS × 2` (one missed beat + /// tolerated). After the first heartbeat the worst-case expiry drops to + /// this value. + pub const RECONCILE_HEARTBEAT_SECONDS: u64 = 30; + /// Interval between heartbeat renewals. Short enough that the first renewal + /// fires well before `RECONCILE_LOCK_SECONDS` expires. + pub const RECONCILE_HEARTBEAT_INTERVAL_SECONDS: u64 = 10; /// Single-flight lock for JMAP push registration. Outlives the maximum JMAP /// request timeout so a slow create cannot admit a duplicate. pub const PUSH_REGISTER_LOCK_SECONDS: u64 = 360; @@ -1586,13 +1595,22 @@ mod tests { assert_eq!(ttl::WEEK, 604_800); assert_eq!(ttl::YEAR, 31_536_000); - assert_eq!(ttl::RECONCILE_LOCK_SECONDS, 300); - assert_eq!(ttl::RECONCILE_HEARTBEAT_SECONDS, 90); + assert_eq!(ttl::RECONCILE_LOCK_SECONDS, 60); + assert_eq!(ttl::RECONCILE_HEARTBEAT_SECONDS, 30); + assert_eq!(ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS, 10); const { assert!( ttl::RECONCILE_HEARTBEAT_SECONDS < ttl::RECONCILE_LOCK_SECONDS, "the heartbeat must renew before the reconcile lock expires" ); + assert!( + ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS * 2 <= ttl::RECONCILE_HEARTBEAT_SECONDS, + "one missed heartbeat must not push renewal past the beat TTL" + ); + assert!( + ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS < ttl::RECONCILE_LOCK_SECONDS, + "the first renewal must fire before the initial hold expires" + ); } assert_eq!(ttl::PUSH_REGISTER_LOCK_SECONDS, 360); assert_eq!(ttl::PUSH_VERIFY_LIMIT_SECONDS, 30);