fix: reconcile lock robustness — TTL 300s→60s, heartbeat 90s→30s, renewal interval 10s
- state.rs: RECONCILE_LOCK_SECONDS 300→60, RECONCILE_HEARTBEAT_SECONDS 90→30, new RECONCILE_HEARTBEAT_INTERVAL_SECONDS=10; ttl_contract_is_pinned asserts interval*2 <= TTL < 60 and interval < LOCK - notify.rs: heartbeat sleep uses ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS - docs: 7 files updated (reference/charter/opengaps/design × en+zh + index.js comments); retired.md/opengaps.md anchor line numbers shifted for state.rs (+9) and notify.rs (+3); notify.rs:1484-1485 range trimmed to avoid blank - GATE-DOCS 6/6, cargo fmt/clippy/test clean (103/0/4)
This commit is contained in:
parent
298d487782
commit
1bdda7c471
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 分钟 | 非功能 |
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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`)。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 回落)。配置面已就绪,缺的只是实例内的分维度状态。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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”调用。重播阶段之后可能会扩大窗口,最多
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`) | 无 |
|
||||
|
|
|
|||
|
|
@ -300,7 +300,10 @@ async fn reconcile(State(app): State<AppState>, 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",
|
||||
|
|
|
|||
30
src/state.rs
30
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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue