test: assert the three Redis TTL write points on a real Redis (opt-in)
real_redis_ttl_tests in src/state.rs closes the "no test asserts any TTL value" gap for the three atomic write points that actually reach Redis: - set_nx_ex SET NX EX, with .max(1) floor (Redis rejects a zero EX) - retry_or_dlq EXPIRE KEYS[1] 86400, embedded in the Lua script - set_ai_consent SET EX Each is opt-in via REDIS_TEST_URL (#[ignore], mirrors the JMAP gating in src/domain/jmap/client.rs:522); missing the env var prints 'skipped' and returns, so CI stays green without credentials and the URL is never logged. retry_or_dlq uses max_attempts=99 so the script returns before XADD/XACK and no stream or group needs to be created. opengaps.md item 2 is now blocked only on a reachable Redis URL. Two defects in that section were fixed while touching it: the consent table rendered 7 * 86_400 as 604_800 and 365 * 86_400 as 31_536_000 (arithmetic right, literal wrong) and listed 300s/7200s values that do not exist in the code - the sixth real consent TTL is 0 (revoke). 16 line anchors had also drifted onto comments and neighbours; all are re-pointed. The line- range audit passes 138 refs but only checks that a line is non-blank, not that it holds the claimed content, which is how these slipped through. GATE-P0 baseline moves to 88 passed / 0 failed / 4 ignored.
This commit is contained in:
parent
e130cb1dfb
commit
0890eb18f0
|
|
@ -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'
|
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。
|
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` 在代码侧关掉,不再需要停机窗口。详见下文「已关闭」。
|
上一轮需要停机窗口的第 1 项(`Email/changes` 的 `newState` 语义)已由提交 `269c8f6` 在代码侧关掉,不再需要停机窗口。详见下文「已关闭」。
|
||||||
|
|
||||||
上一轮真机联调已经关掉了两条阻塞:
|
上一轮真机联调已经关掉了两条阻塞:
|
||||||
|
|
||||||
- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。
|
- **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,本文件不再跟踪它们。
|
两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。
|
||||||
|
|
||||||
|
|
@ -45,9 +45,11 @@
|
||||||
|
|
||||||
### 1. TTL 实测
|
### 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 个值**
|
**A. 调用点字面量参数 — 10 个写入点 / 10 个值**
|
||||||
|
|
||||||
|
|
@ -59,7 +61,7 @@
|
||||||
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1210` |
|
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1210` |
|
||||||
| `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` |
|
| `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/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:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` |
|
||||||
| `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` |
|
| `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` |
|
||||||
| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1426` |
|
| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1426` |
|
||||||
|
|
@ -68,24 +70,26 @@
|
||||||
|
|
||||||
| 键 | 用途 | TTL | 写入点 |
|
| 键 | 用途 | 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 个值**
|
**C. `consent:ai:{chat_id}` — 1 个写入点 / 6 个值**
|
||||||
|
|
||||||
| 值 | 触发 | 说明 | 产生处 |
|
| 值 | 触发 | 说明 | 产生处 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `3600` | 「临时 / 一次」 | 1 小时 | `src/worker.rs:568` |
|
| `3600` | 「临时 / 一次」 | 1 小时 | `src/worker.rs:585-586` |
|
||||||
| `86_400` | 「今天」 | 1 天 | `src/worker.rs:574` |
|
| `86_400` | 「今天」 | 1 天 | `src/worker.rs:591-592` |
|
||||||
| `7 * 86_400` | 「7天」 | 7 天 | `src/worker.rs:580` |
|
| `7 * 86_400` | 「7天」 | 7 天 | `src/worker.rs:597-598` |
|
||||||
| `365 * 86_400` | 「直到撤销 / 长期」 | 最长 365 天 | `src/worker.rs:586` |
|
| `365 * 86_400` | 「直到撤销 / 长期」 | 最长 365 天 | `src/worker.rs:603-604` |
|
||||||
| `3600` | `/ai on` 等显式开启 | 1 小时 | `src/worker.rs:597` |
|
| `3600` | `/ai on` 等显式开启 | 1 小时 | `src/worker.rs:614-615` |
|
||||||
| `0` | `/ai off` 撤销 | 落到 `max(1)` = 1s,且已过期 | `src/worker.rs:607` |
|
| `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 处行号锚点漂移。
|
||||||
|
|
||||||
## 后续(多账号)
|
## 后续(多账号)
|
||||||
|
|
||||||
|
|
|
||||||
128
src/state.rs
128
src/state.rs
|
|
@ -1621,3 +1621,131 @@ mod tests {
|
||||||
state.ack("events-dlq", "workers", &id).await.unwrap();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue