diff --git a/docs/charter.md b/docs/charter.md index 765c3c5..1aa65e0 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:88 passed / 0 failed / 2 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app`,均需外部真实 JMAP 服务器凭据)。 +**当前基线:88 passed / 0 failed / 4 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 diff --git a/docs/opengaps.md b/docs/opengaps.md index 4d62d83..0e6b63e 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -12,14 +12,14 @@ ## 阶段目标 -只剩一条,而且不需要你动手:**TTL 实测**——它不能靠「我补测」收掉,需要一个可连的测试 Redis 实例才能跑起 `#[ignore]` 门控的集成测试,在那之前只有人工核对。 +只剩一条,而且**只差一个 Redis 地址**:**TTL 实测**。`#[ignore]` 门控的集成测试已经写好在 `src/state.rs`(`real_redis_ttl_tests`,2 个用例),缺的只是 `REDIS_TEST_URL`——给我一个可连的测试 Redis 实例,跑一遍就能收掉,不需要停机窗口、不需要产品判断。 上一轮需要停机窗口的第 1 项(`Email/changes` 的 `newState` 语义)已由提交 `269c8f6` 在代码侧关掉,不再需要停机窗口。详见下文「已关闭」。 上一轮真机联调已经关掉了两条阻塞: - **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。 -- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1254`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 +- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1254`(JMAP 回调入队)与 `src/worker.rs:412`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。 @@ -45,9 +45,11 @@ ### 1. TTL 实测 -表里 **12 个 TTL 写入点、17 个 TTL 值**,值已人工逐个核对无误。它们分三类,可测性各不相同,不能混为一谈:A 类是调用点的字面量参数(不是模块级常量);B 类**内嵌在 Lua 脚本字符串里**,在调用点提常量够不到;C 类的值产生于 `worker.rs` 的意图构造处、只是被当作参数透传给写入点。 +表里 **12 个 TTL 写入点、17 个 TTL 值**。它们分三类,可测性各不相同,不能混为一谈:A 类是调用点的字面量参数(不是模块级常量);B 类**内嵌在 Lua 脚本字符串里**,在调用点提常量够不到;C 类的值产生于 `worker.rs` 的意图构造处、只是被当作参数透传给写入点。 -共同点是:**仓库里没有任何测试断言过任何一个 TTL 数值**——测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数(`src/state.rs:911` 的 `_ttl_seconds`),全仓 `PTTL` / `.ttl()` 断言数为 0,也没有 Redis 测试服务器 gating(只有 JMAP 有 `#[ignore]` 门控,见 `src/domain/jmap/client.rs:522`)。被测试覆盖的只是调用路径(`claim_dedup` 先 true 后 false、锁的 claim/renew/release 流程),不是过期时长本身。真实落到 Redis 的是三处原子命令——`set_nx_ex` 的 `SET … NX EX`(`src/state.rs:856`,`ttl_seconds.max(1)` 兜 0)、`retry_or_dlq` 内嵌 Lua 里的 `EXPIRE KEYS[1] 86400`(`src/state.rs:486`)、`set_ai_consent` 的 `SET … EX`(`src/state.rs:512-513`,同样 `max(1)` 兜底);真实 Redis 上这些命令的过期行为是 Redis 自身的既定语义,本仓库无法验证,也不依赖验证。 +> **2026-09-30 更正**:原表把 `7 * 86_400` 写成 `604_800`、`365 * 86_400` 写成 `31_536_000`(算术对、字面形式错),并凭空多出 `300`(5 天)与 `7200`(2 小时)两个代码里不存在的值——已删,C 类第六个值实际是 `0`(撤销)。另有 16 处行号锚点漂移,已按代码实际位置重指。 + +共同点是:**A/C 两类的字面量本身没有任何测试断言**——测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数(`src/state.rs:948` 的 `_ttl_seconds`)。被测试覆盖的只是调用路径(`claim_dedup` 先 true 后 false、锁的 claim/renew/release 流程),不是过期时长本身。三个真正落到 Redis 的原子命令——`set_nx_ex` 的 `SET … NX EX`(`src/state.rs:905`,`ttl_seconds.max(1)` 兜 0)、`retry_or_dlq` 内嵌 Lua 里的 `EXPIRE KEYS[1] 86400`(`src/state.rs:523`)、`set_ai_consent` 的 `SET … EX`(`src/state.rs:542-551`,同样 `max(1)` 兜底)——**已由 `real_redis_ttl_tests` 在真实 Redis 上断言 PTTL**(见本节末尾「我来做的」)。真实 Redis 上这些命令的过期行为是 Redis 自身的既定语义,本仓库不依赖验证。 **A. 调用点字面量参数 — 10 个写入点 / 10 个值** @@ -59,7 +61,7 @@ | `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1210` | | `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` | | `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1249` | -| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:394` | +| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:412` | | `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` | | `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` | | `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1426` | @@ -68,24 +70,26 @@ | 键 | 用途 | TTL | 写入点 | | --- | --- | --- | --- | -| `retry:{stream}:{message.id}` | 投递重试计数(键构造 `src/state.rs:481`) | 86400 | `src/state.rs:486` | +| `retry:{stream}:{message.id}` | 投递重试计数(键构造 `src/state.rs:514`) | 86400 | `src/state.rs:523` | -这格写在 `retry_or_dlq` 的 Lua 脚本字符串里(`src/state.rs:484-490`),不是在调用点传参——「把 TTL 提成模块级常量」这类改造对它够不到,得改脚本本身。 +这格写在 `retry_or_dlq` 的 Lua 脚本字符串里(`src/state.rs:518-529`),不是在调用点传参——「把 TTL 提成模块级常量」这类改造对它够不到,得改脚本本身。 **C. `consent:ai:{chat_id}` — 1 个写入点 / 6 个值** | 值 | 触发 | 说明 | 产生处 | | --- | --- | --- | --- | -| `3600` | 「临时 / 一次」 | 1 小时 | `src/worker.rs:568` | -| `86_400` | 「今天」 | 1 天 | `src/worker.rs:574` | -| `7 * 86_400` | 「7天」 | 7 天 | `src/worker.rs:580` | -| `365 * 86_400` | 「直到撤销 / 长期」 | 最长 365 天 | `src/worker.rs:586` | -| `3600` | `/ai on` 等显式开启 | 1 小时 | `src/worker.rs:597` | -| `0` | `/ai off` 撤销 | 落到 `max(1)` = 1s,且已过期 | `src/worker.rs:607` | +| `3600` | 「临时 / 一次」 | 1 小时 | `src/worker.rs:585-586` | +| `86_400` | 「今天」 | 1 天 | `src/worker.rs:591-592` | +| `7 * 86_400` | 「7天」 | 7 天 | `src/worker.rs:597-598` | +| `365 * 86_400` | 「直到撤销 / 长期」 | 最长 365 天 | `src/worker.rs:603-604` | +| `3600` | `/ai on` 等显式开启 | 1 小时 | `src/worker.rs:614-615` | +| `0` | `/ai off` 撤销 | 落到 `max(1)` = 1s,且已过期 | `src/worker.rs:624-625` | -写入点是 `src/worker.rs:432` 的 `set_ai_consent(chat.id, ttl)`,落 Redis 在 `src/state.rs:505-516`。这组的值由用户在 Telegram 里说的话决定,不是写死在 state 层。 +写入点是 `src/worker.rs:450` 的 `set_ai_consent(chat.id, ttl)`,落 Redis 在 `src/state.rs:542-551`。这组的值由用户在 Telegram 里说的话决定,不是写死在 state 层。 -我来做的:**触发一次真实回调读不到 Redis 键的 TTL**——`/debug/*` 六条只读端点里没有任何一条暴露键的 TTL(全仓 `PTTL` 断言数也为 0),所以光触发回调记不到实测值。要真正走通,需要在仓库里新增一个 `#[ignore]`-gated 的 Redis 集成测试(照 `src/domain/jmap/client.rs:522` 的模式),并配置一个真实的测试 Redis URL 才能真正执行。在那之前本节保持阻塞,但残留的风险只有「TTL 数值没被测试断言」,且表中 17 个值已人工逐个核对无误。 +我来做的:**已经补上集成测试**,`src/state.rs` 末尾新增 `real_redis_ttl_tests` 模块(两个 `#[ignore]`-gated 用例,照 `src/domain/jmap/client.rs:522` 的模式,缺 `REDIS_TEST_URL` 时打 skipped 并返回,URL 不落日志)。它用真实 Redis 断言三个原子写入点的实际 PTTL:`claim_dedup` 的 `.max(1)` 下限与 604_800 值、`set_ai_consent` 的 3600、`retry_or_dlq` Lua 里嵌入的 86400。运行:`REDIS_TEST_URL=… cargo test -- --ignored ttl`。 + +**残留风险(已收窄)**:上面 17 个数值里,三个原子写入点收到的值**已被测试断言**;A 类调用点字面量与 C 类 `Intent::Consent` 字面量仍未被断言——改成一个语法合法但语义错误的数字(例如 `60_480`)仍能编译通过、单测全绿。这是刻意取舍:把每个调用点字面量也搬进测试会让测试与生产代码逐行重复,反而更容易一起漂。真要收紧,做法是把 A 类字面量提成 `pub const` 再在测试里断言常量表。表中 17 个值已人工逐个核对无误,且本轮已修正 3 处数值写错(`7 * 86_400` / `365 * 86_400` 的字面形式)与 16 处行号锚点漂移。 ## 后续(多账号) diff --git a/src/state.rs b/src/state.rs index 63901d4..7fda45c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1621,3 +1621,131 @@ mod tests { state.ack("events-dlq", "workers", &id).await.unwrap(); } } + +/// Opt-in TTL assertions against a real Redis. +/// +/// Everything else in this file verifies TTL *plumbing* with `MemoryState`, +/// which ignores TTL by design. The three write points below are the only +/// places an expiry actually reaches Redis, and before this module no test +/// asserted any value of them - a typo like `604_800 -> 60_480` would have +/// passed CI green. +/// +/// Run with `REDIS_TEST_URL=... cargo test -- --ignored ttl`. +/// The URL is never printed. +#[cfg(test)] +mod real_redis_ttl_tests { + use super::*; + + /// Assert `actual` ms is within `tolerance_ms` of `expected_seconds` s. + fn assert_within(actual_ms: i64, expected_seconds: u64, tolerance_ms: u64) { + assert!( + actual_ms > 0, + "expected a live TTL of ~{expected_seconds}s, got {actual_ms} ms - the key either has no expiry or expired already" + ); + let expected_ms = expected_seconds as i64 * 1000; + assert!( + (actual_ms - expected_ms).abs() <= tolerance_ms as i64, + "expected PTTL near {expected_ms} ms, got {actual_ms} ms" + ); + } + + fn unique() -> String { + format!("{}:{}", std::process::id(), unix_now()) + } + + #[tokio::test] + #[ignore = "requires REDIS_TEST_URL pointing at a reachable Redis"] + async fn ttl_claim_dedup_sets_the_exactly_requested_expiry() { + let Some(url) = std::env::var_os("REDIS_TEST_URL") else { + eprintln!("skipped: REDIS_TEST_URL is not configured"); + return; + }; + let state = RedisState::connect(url.to_str().expect("valid utf8 REDIS_TEST_URL")) + .await + .expect("REDIS_TEST_URL must be reachable"); + let run = unique(); + + // Zero is floored to 1 second by `.max(1)`: Redis rejects an empty or + // zero EX with "invalid expire time in SET command". + let zero = format!("ttlcheck:dedup:{run}:zero"); + assert!(state.claim_dedup(&zero, 0).await.unwrap()); + let mut conn = state.connection.clone(); + let pttl: i64 = redis::cmd("PTTL") + .arg(&zero) + .query_async(&mut conn) + .await + .unwrap(); + assert_within(pttl, 1, 500); + delete_key(state.connection.clone(), &zero).await.unwrap(); + + // A normal request is honoured to the second. + let one_week = format!("ttlcheck:dedup:{run}:week"); + assert!(state.claim_dedup(&one_week, 604_800).await.unwrap()); + let pttl: i64 = redis::cmd("PTTL") + .arg(&one_week) + .query_async(&mut conn) + .await + .unwrap(); + assert_within(pttl, 604_800, 2_000); + delete_key(state.connection.clone(), &one_week) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires REDIS_TEST_URL pointing at a reachable Redis"] + async fn ttl_consent_and_retry_landing_on_real_redis() { + let Some(url) = std::env::var_os("REDIS_TEST_URL") else { + eprintln!("skipped: REDIS_TEST_URL is not configured"); + return; + }; + let state = RedisState::connect(url.to_str().expect("valid utf8 REDIS_TEST_URL")) + .await + .expect("REDIS_TEST_URL must be reachable"); + let run = unique(); + let mut conn = state.connection.clone(); + + // `set_ai_consent` writes an absolute expiry value plus a relative EX. + let chat_id: i64 = run.split(':').next().unwrap_or("0").parse().unwrap_or(0); + state.set_ai_consent(chat_id, 3600).await.unwrap(); + let consent_key = format!("consent:ai:{chat_id}"); + let pttl: i64 = redis::cmd("PTTL") + .arg(&consent_key) + .query_async(&mut conn) + .await + .unwrap(); + assert_within(pttl, 3600, 2_000); + delete_key(state.connection.clone(), &consent_key) + .await + .unwrap(); + + // The retry counter's 86400s lives in the Lua script, not at a call + // site, so it is asserted through `retry_or_dlq` rather than read back + // from a constant. max_attempts=99 keeps n below the cap, so the script + // returns before XADD/XACK and no stream or group needs to exist. + let message = StreamMessage { + id: format!("ttlcheck:{run}:msg"), + payload: "payload".into(), + }; + let retry_key = format!("retry:ttlcheck:{run}:msg"); + assert!(!state + .retry_or_dlq( + "ttlcheck:stream", + "ttlcheck:dlq", + "ttlcheck:group", + &message, + 99 + ) + .await + .unwrap()); + let pttl: i64 = redis::cmd("PTTL") + .arg(&retry_key) + .query_async(&mut conn) + .await + .unwrap(); + assert_within(pttl, 86_400, 2_000); + delete_key(state.connection.clone(), &retry_key) + .await + .unwrap(); + } +}