[C-03][high] 保留清理放在每秒循环里:全表扫描、单事务大删除、到期处理无 LIMIT、没有 wal_checkpoint;断线清标记 SQL 缺条件;僵尸不保留投递永不过期 #34

Closed
opened 2026-09-30 13:56:58 +08:00 by nixevol · 2 comments
Owner

编号:C-03 严重级:high 工作线:消息核心(internal/app/message、serve 的 messageLoops) 来源:审查 M-05、M-07、M-09、M-10
依赖:C-01 (#32)(清理循环调度)、D-02 (#28)(非事务执行接口) 被依赖:无

结论与统一方案

合并审查 M-05、M-07、M-09 与 M-10 的消息侧部分:

  1. 拆分:ExpireOnce 每秒跑,每个写操作最多处理 500 条,循环到做完或用完时间预算;PurgeOnce 每小时跑,三类删除(过期记录、回执、防重行)各自独立成写操作、分批执行(每批几千行;先按批删投递再删消息,避免 1000 人群的级联一次删 500 万行)。(审查 M-05)
  2. 索引:新增迁移 0003,建 idx_receipts_created(created_at)、idx_send_keys_created(created_at);记录清理改写成 WHERE +state='completed' AND created_at < ?,走 idx_messages_created。(审查 M-05)
  3. checkpoint:清理后通过 D-02 提供的非事务接口执行 PRAGMA wal_checkpoint(TRUNCATE),可顺带 PRAGMA optimize。(审查 M-05)
  4. 保留 0 天:分批删除所有 completed 消息,覆盖经 identity/group 路径完成的消息。(审查 M-07)
  5. 断线 SQL:OnDisconnect 清推送标记的 UPDATE 加 AND endpoint_id = ?,走 idx_deliveries_outbox。(审查 M-09)
  6. 僵尸不保留投递:dispatchFullTx 的离线分支同时读 online_since,库里仍显示在线就按"刚断线"处理(expire_at = now + grace);每秒到期处理补上"没有就绪连接、不保留、未推送且 expire_at 为空"投递的宽限截止时间。顺序问题(先移除连接表再写库)在 B-09 修。(审查 M-10)

改动文件

internal/app/message/recover.go、session.go、dispatch.go;internal/store/migrations/0003_*.sql。

与其他问题的交互 / 冲突说明

  • 依赖 C-01、D-02。
  • 迁移号 0003 归本条;其他需要迁移的 issue 顺延(U-02 若加外键用 0004;C-07 的 completed_at 用 0005,或与本条合并为 0003)。

验收与测试

  • 三条清理语句的执行计划没有 SCAN receipts、SCAN sk(按 modernc 实际驱动复核)。
  • 用可注入时钟验证 purge 每小时只跑一次;purge 后 WAL 回落;到期处理分批完成、不遗漏。
  • 保留 0 天,分别走停用、解散、定时消息作废三条路径,清理后 messages 表没有 completed 行。
  • 断线 UPDATE 的执行计划走 idx_deliveries_outbox。
  • 构造僵尸投递,宽限过后变为 dropped。

问题明细(各区审查原文,证据含文件与行号)

以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。

[M-05] 每小时一次的保留清理被放进每秒循环:全表扫描、单事务大删除、没有 wal_checkpoint

  • 严重级:high
  • 分类:性能 / 数据 / 与文档不符
  • 现象与影响:
    • CleanupOnce 每秒执行一次,而且在同一个写操作里(单写 goroutine)同时做三件事:到期投递、删过期记录、删回执和防重行。
    • 内存 SQLite 的执行计划显示:
      • 回执清理是 SCAN receipts(全表扫描)。
      • 防重行清理是 SCAN sk,外加每行一次子查询。
      • 记录清理走的是 idx_messages_due (state=?),等于扫描全部已完成的消息。
    • 按 PRD 第 8 节容量(每天 100 万条、保留 7 天,约 700 万行),每秒都要扫几百万行,而且在写锁内。提交、确认、推送标记全部排队,延迟和吞吐都达不到要求。
    • 每批删 5000 条消息会级联删除它们的全部投递行:1000 人群就是 500 万行,违背「每批几千行」。
    • 到期处理没有 LIMIT:重启后宽限一到,所有没重连的投递会在一个写操作里逐条处理。
    • 没有执行 PRAGMA wal_checkpoint(TRUNCATE),已删除的正文会留在 WAL 文件里。
  • 证据:recover.go:43-116(整段在一个 Queue.Do 里);recover.go:44-50(没有 LIMIT);recover.go:81-114(三类删除);serve.go:339(每秒调用);全仓没有 wal_checkpoint。
  • 文档依据:
    • DEVELOPMENT 7.5:清理循环每秒一次,只处理到期投递。
    • DEVELOPMENT 7.6:「每小时清理一次……分批删除(每批几千行),不要一条语句删几百万行卡住写队列。清理后执行一次 PRAGMA wal_checkpoint(TRUNCATE)」。
    • TASKS M3:「每小时清理(……分批删、wal_checkpoint)」。
  • 为何不是故意设计:DEVIATIONS 里没有任何相关说明。
  • 解决方案:
    1. 拆成两部分:ExpireOnce 每秒跑,每个写操作最多处理 500 条,循环到做完或用完时间预算;PurgeOnce 每小时跑,三类删除各自独立成写操作、分批执行。
    2. 新增迁移 0003:建 idx_receipts_created(created_at) 和 idx_send_keys_created(created_at)。记录清理改写成 WHERE +state='completed' AND created_at < ?,让它走 idx_messages_created。级联问题的处理:先按批删投递,或者缩小每批消息数。
    3. 清理后执行 PRAGMA wal_checkpoint(TRUNCATE)。它不能在事务里跑,需要 store 提供一个在写 goroutine 上执行非事务语句的接口。可以顺带每小时跑一次 PRAGMA optimize。
  • 改动文件:internal/app/message/recover.go、cmd/nixmsg/serve.go;internal/store/migrations/0003_*.sql、internal/store/queue.go(平台线/共享文件)。
  • 交互/冲突风险:迁移属于共享文件,按 TASKS 4.2 处理;store 新接口需要平台线配合。
  • 需补测试:
    • 对三条清理语句断言执行计划里没有 SCAN receipts、SCAN sk。
    • 用可注入时钟验证 purge 每小时只跑一次。
    • purge 后 WAL 文件大小回落。
    • 到期处理分批完成、不遗漏。
  • 置信度:代码阅读确定,执行计划已在 SQLite 3.53.1 上核实(modernc 需复核一次)。

[M-07] 记录保留天数设为 0 时,经 identity/group 路径完成的消息永远不删

  • 严重级:medium
  • 分类:数据 / 与 PRD 不符
  • 现象与影响:
    • message 包自己的收尾函数 finalizeMessageTx 在保留 0 天时会删除消息行。
    • identity 的 tryFinalizeTx,以及 group/identity 完成 scheduled 消息的路径都不删行。
    • CleanupOnce 在保留天数为 0 时直接跳过删除,于是这些 completed 记录连同它们的投递行、防重行永久残留,后台还能看到。
  • 证据:recover.go:81(if RecordRetentionDays > 0);identity/lifecycle.go:601-624;group/void.go:132-150。
  • 文档依据:PRD F18「设为 0 则完成后连记录一起删除,后台只能看到尚未完成的消息」。
  • 为何不是故意设计:DEVIATIONS 里没有相关说明。
  • 解决方案:
    1. 清理逻辑在保留天数为 0 时,分批删除所有 completed 消息,不论新旧。
    2. M-06 的统一终态函数上线后,路径层面也会一致。
  • 改动文件:internal/app/message/recover.go。
  • 交互/冲突风险:与 M-05 拆出的 PurgeOnce 放在一起做。
  • 需补测试:保留 0 天,分别走停用、解散、定时消息作废三条路径,清理后 messages 表中没有 completed 行。
  • 置信度:代码阅读确定。

[M-09] 断线清推送标记的 SQL 没带编号条件,每次断线扫描全库所有 pending 投递

  • 严重级:medium
  • 分类:性能
  • 现象与影响:
    • OnDisconnect 最后一条 UPDATE 的条件是 WHERE state='pending' AND pushed_conn=?,执行计划是 idx_deliveries_expire (state=?),也就是遍历所有 pending 投递。
    • 离线保留的积压越大越慢,而且在写锁内执行。网络抖动导致成百上千个端同时断线时,写队列会被拖住。
  • 证据:session.go:63-66;执行计划已核实。
  • 文档依据:DEVELOPMENT 7.2 写入性能要求;PRD 第 8 节(1000 在线、每秒 200 条)。
  • 为何不是故意设计:函数本身就有编号参数,只是漏写了条件。
  • 解决方案:加上 AND endpoint_id = ?,改走 idx_deliveries_outbox。连接代号本身就只属于一个编号,语义不变。
  • 改动文件:internal/app/message/session.go。
  • 交互/冲突风险:无。
  • 需补测试:执行计划断言走 idx_deliveries_outbox;已有的断线用例继续通过。
  • 置信度:代码阅读确定,执行计划已核实。

[M-10] 断线写库与分发之间有竞态,会产生永不过期的「僵尸」不保留投递

  • 严重级:medium
  • 分类:并发 / 逻辑
  • 现象与影响:
    • appUplink.OnDisconnect 先调用 msg.OnDisconnect(入队写操作并等待提交),然后才从连接表里移除这个连接。
    • 在这段时间里执行的分发操作仍会把接收端判为在线,插入一条 expire_at 为空的不保留投递。
    • 断线写操作只修正在它之前已存在的投递,这一条漏了。
    • 清理只处理 expire_at IS NOT NULL 的投递,所以它永远不会被丢弃。接收端不再上线的话,消息永远停在 dispatched,正文永不删除,还占着双方配额。
  • 证据:uplink.go:56-61(先写库、后移除);dispatch.go:148(事务内查内存连接表);recover.go:49(清理条件)。
  • 文档依据:PRD F10「宽限结束仍不在则丢弃」;F18。
  • 为何不是故意设计:DEVIATIONS 里没有相关说明。
  • 解决方案:
    1. OnDisconnect 先计算 isCurrent,再从连接表移除,最后调 msg.OnDisconnect。会话层写 offline_since 发生在此之前,所以之后的分发能按新的离线时刻正确计算宽限。
    2. dispatchFullTx 的离线分支同时读 online_since:如果数据库里仍显示在线,就按「刚断线」处理,expire_at = now + grace。
    3. 每秒到期处理里加兜底:把没有就绪连接、不保留、未推送且 expire_at 为空的投递补上宽限截止时间。
  • 改动文件:cmd/nixmsg/uplink.go;internal/app/message/dispatch.go、recover.go。
  • 交互/冲突风险:与 M-03 的就绪标记一起实现。
  • 需补测试:保持连接表里还有该连接时调 OnDisconnect,随后提交一条不保留消息,再移除连接;运行兜底后投递得到宽限,宽限过后变为 dropped。
  • 置信度:代码阅读确定(时间窗口约为一个写批次加一次落盘)。

复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。

**编号**:C-03 **严重级**:high **工作线**:消息核心(internal/app/message、serve 的 messageLoops) **来源**:审查 M-05、M-07、M-09、M-10 **依赖**:C-01 (#32)(清理循环调度)、D-02 (#28)(非事务执行接口) **被依赖**:无 ### 结论与统一方案 合并审查 M-05、M-07、M-09 与 M-10 的消息侧部分: 1. **拆分**:`ExpireOnce` 每秒跑,每个写操作最多处理 500 条,循环到做完或用完时间预算;`PurgeOnce` 每小时跑,三类删除(过期记录、回执、防重行)各自独立成写操作、分批执行(每批几千行;先按批删投递再删消息,避免 1000 人群的级联一次删 500 万行)。(审查 M-05) 2. **索引**:新增迁移 `0003`,建 `idx_receipts_created(created_at)`、`idx_send_keys_created(created_at)`;记录清理改写成 `WHERE +state='completed' AND created_at < ?`,走 `idx_messages_created`。(审查 M-05) 3. **checkpoint**:清理后通过 D-02 提供的非事务接口执行 `PRAGMA wal_checkpoint(TRUNCATE)`,可顺带 `PRAGMA optimize`。(审查 M-05) 4. **保留 0 天**:分批删除所有 completed 消息,覆盖经 identity/group 路径完成的消息。(审查 M-07) 5. **断线 SQL**:`OnDisconnect` 清推送标记的 UPDATE 加 `AND endpoint_id = ?`,走 `idx_deliveries_outbox`。(审查 M-09) 6. **僵尸不保留投递**:`dispatchFullTx` 的离线分支同时读 `online_since`,库里仍显示在线就按"刚断线"处理(`expire_at = now + grace`);每秒到期处理补上"没有就绪连接、不保留、未推送且 expire_at 为空"投递的宽限截止时间。顺序问题(先移除连接表再写库)在 B-09 修。(审查 M-10) ### 改动文件 `internal/app/message/recover.go`、`session.go`、`dispatch.go`;`internal/store/migrations/0003_*.sql`。 ### 与其他问题的交互 / 冲突说明 - 依赖 C-01、D-02。 - 迁移号 0003 归本条;其他需要迁移的 issue 顺延(U-02 若加外键用 0004;C-07 的 `completed_at` 用 0005,或与本条合并为 0003)。 ### 验收与测试 - 三条清理语句的执行计划没有 `SCAN receipts`、`SCAN sk`(按 modernc 实际驱动复核)。 - 用可注入时钟验证 purge 每小时只跑一次;purge 后 WAL 回落;到期处理分批完成、不遗漏。 - 保留 0 天,分别走停用、解散、定时消息作废三条路径,清理后 `messages` 表没有 completed 行。 - 断线 UPDATE 的执行计划走 `idx_deliveries_outbox`。 - 构造僵尸投递,宽限过后变为 dropped。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [M-05] 每小时一次的保留清理被放进每秒循环:全表扫描、单事务大删除、没有 wal_checkpoint - 严重级:high - 分类:性能 / 数据 / 与文档不符 - 现象与影响: - `CleanupOnce` 每秒执行一次,而且在同一个写操作里(单写 goroutine)同时做三件事:到期投递、删过期记录、删回执和防重行。 - 内存 SQLite 的执行计划显示: - 回执清理是 `SCAN receipts`(全表扫描)。 - 防重行清理是 `SCAN sk`,外加每行一次子查询。 - 记录清理走的是 `idx_messages_due (state=?)`,等于扫描全部已完成的消息。 - 按 PRD 第 8 节容量(每天 100 万条、保留 7 天,约 700 万行),每秒都要扫几百万行,而且在写锁内。提交、确认、推送标记全部排队,延迟和吞吐都达不到要求。 - 每批删 5000 条消息会级联删除它们的全部投递行:1000 人群就是 500 万行,违背「每批几千行」。 - 到期处理没有 LIMIT:重启后宽限一到,所有没重连的投递会在一个写操作里逐条处理。 - 没有执行 `PRAGMA wal_checkpoint(TRUNCATE)`,已删除的正文会留在 WAL 文件里。 - 证据:`recover.go:43-116`(整段在一个 `Queue.Do` 里);`recover.go:44-50`(没有 LIMIT);`recover.go:81-114`(三类删除);`serve.go:339`(每秒调用);全仓没有 `wal_checkpoint`。 - 文档依据: - DEVELOPMENT 7.5:清理循环每秒一次,只处理到期投递。 - DEVELOPMENT 7.6:「每小时清理一次……分批删除(每批几千行),不要一条语句删几百万行卡住写队列。清理后执行一次 `PRAGMA wal_checkpoint(TRUNCATE)`」。 - TASKS M3:「每小时清理(……分批删、wal_checkpoint)」。 - 为何不是故意设计:DEVIATIONS 里没有任何相关说明。 - 解决方案: 1. 拆成两部分:`ExpireOnce` 每秒跑,每个写操作最多处理 500 条,循环到做完或用完时间预算;`PurgeOnce` 每小时跑,三类删除各自独立成写操作、分批执行。 2. 新增迁移 `0003`:建 `idx_receipts_created(created_at)` 和 `idx_send_keys_created(created_at)`。记录清理改写成 `WHERE +state='completed' AND created_at < ?`,让它走 `idx_messages_created`。级联问题的处理:先按批删投递,或者缩小每批消息数。 3. 清理后执行 `PRAGMA wal_checkpoint(TRUNCATE)`。它不能在事务里跑,需要 store 提供一个在写 goroutine 上执行非事务语句的接口。可以顺带每小时跑一次 `PRAGMA optimize`。 - 改动文件:`internal/app/message/recover.go`、`cmd/nixmsg/serve.go`;`internal/store/migrations/0003_*.sql`、`internal/store/queue.go`(平台线/共享文件)。 - 交互/冲突风险:迁移属于共享文件,按 TASKS 4.2 处理;store 新接口需要平台线配合。 - 需补测试: - 对三条清理语句断言执行计划里没有 `SCAN receipts`、`SCAN sk`。 - 用可注入时钟验证 purge 每小时只跑一次。 - purge 后 WAL 文件大小回落。 - 到期处理分批完成、不遗漏。 - 置信度:代码阅读确定,执行计划已在 SQLite 3.53.1 上核实(modernc 需复核一次)。 #### [M-07] 记录保留天数设为 0 时,经 identity/group 路径完成的消息永远不删 - 严重级:medium - 分类:数据 / 与 PRD 不符 - 现象与影响: - message 包自己的收尾函数 `finalizeMessageTx` 在保留 0 天时会删除消息行。 - identity 的 `tryFinalizeTx`,以及 group/identity 完成 scheduled 消息的路径都不删行。 - `CleanupOnce` 在保留天数为 0 时直接跳过删除,于是这些 completed 记录连同它们的投递行、防重行永久残留,后台还能看到。 - 证据:`recover.go:81`(`if RecordRetentionDays > 0`);`identity/lifecycle.go:601-624`;`group/void.go:132-150`。 - 文档依据:PRD F18「设为 0 则完成后连记录一起删除,后台只能看到尚未完成的消息」。 - 为何不是故意设计:DEVIATIONS 里没有相关说明。 - 解决方案: 1. 清理逻辑在保留天数为 0 时,分批删除所有 completed 消息,不论新旧。 2. M-06 的统一终态函数上线后,路径层面也会一致。 - 改动文件:`internal/app/message/recover.go`。 - 交互/冲突风险:与 M-05 拆出的 `PurgeOnce` 放在一起做。 - 需补测试:保留 0 天,分别走停用、解散、定时消息作废三条路径,清理后 `messages` 表中没有 completed 行。 - 置信度:代码阅读确定。 #### [M-09] 断线清推送标记的 SQL 没带编号条件,每次断线扫描全库所有 pending 投递 - 严重级:medium - 分类:性能 - 现象与影响: - `OnDisconnect` 最后一条 UPDATE 的条件是 `WHERE state='pending' AND pushed_conn=?`,执行计划是 `idx_deliveries_expire (state=?)`,也就是遍历所有 pending 投递。 - 离线保留的积压越大越慢,而且在写锁内执行。网络抖动导致成百上千个端同时断线时,写队列会被拖住。 - 证据:`session.go:63-66`;执行计划已核实。 - 文档依据:DEVELOPMENT 7.2 写入性能要求;PRD 第 8 节(1000 在线、每秒 200 条)。 - 为何不是故意设计:函数本身就有编号参数,只是漏写了条件。 - 解决方案:加上 `AND endpoint_id = ?`,改走 `idx_deliveries_outbox`。连接代号本身就只属于一个编号,语义不变。 - 改动文件:`internal/app/message/session.go`。 - 交互/冲突风险:无。 - 需补测试:执行计划断言走 `idx_deliveries_outbox`;已有的断线用例继续通过。 - 置信度:代码阅读确定,执行计划已核实。 #### [M-10] 断线写库与分发之间有竞态,会产生永不过期的「僵尸」不保留投递 - 严重级:medium - 分类:并发 / 逻辑 - 现象与影响: - `appUplink.OnDisconnect` 先调用 `msg.OnDisconnect`(入队写操作并等待提交),然后才从连接表里移除这个连接。 - 在这段时间里执行的分发操作仍会把接收端判为在线,插入一条 `expire_at` 为空的不保留投递。 - 断线写操作只修正在它之前已存在的投递,这一条漏了。 - 清理只处理 `expire_at IS NOT NULL` 的投递,所以它永远不会被丢弃。接收端不再上线的话,消息永远停在 `dispatched`,正文永不删除,还占着双方配额。 - 证据:`uplink.go:56-61`(先写库、后移除);`dispatch.go:148`(事务内查内存连接表);`recover.go:49`(清理条件)。 - 文档依据:PRD F10「宽限结束仍不在则丢弃」;F18。 - 为何不是故意设计:DEVIATIONS 里没有相关说明。 - 解决方案: 1. `OnDisconnect` 先计算 isCurrent,再从连接表移除,最后调 `msg.OnDisconnect`。会话层写 `offline_since` 发生在此之前,所以之后的分发能按新的离线时刻正确计算宽限。 2. `dispatchFullTx` 的离线分支同时读 `online_since`:如果数据库里仍显示在线,就按「刚断线」处理,`expire_at = now + grace`。 3. 每秒到期处理里加兜底:把没有就绪连接、不保留、未推送且 `expire_at` 为空的投递补上宽限截止时间。 - 改动文件:`cmd/nixmsg/uplink.go`;`internal/app/message/dispatch.go`、`recover.go`。 - 交互/冲突风险:与 M-03 的就绪标记一起实现。 - 需补测试:保持连接表里还有该连接时调 `OnDisconnect`,随后提交一条不保留消息,再移除连接;运行兜底后投递得到宽限,宽限过后变为 dropped。 - 置信度:代码阅读确定(时间窗口约为一个写批次加一次落盘)。 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P1-highlane/messagereview-2026-09-30 labels 2026-09-30 13:56:58 +08:00
Author
Owner

C-03 已在 feat/fix-message-c01-c03 提交 a4e51ec5cd (#34)

completed_at 分批清理 + wal_checkpoint,迁移 0004;读池上限见 (#29)。未合入 main。

C-03 已在 `feat/fix-message-c01-c03` 提交 https://git.asio.asia/nixevol/NixMsg/commit/a4e51ec5cd688e451d1e7734b2d3c7036ac46b51 (#34) `completed_at` 分批清理 + `wal_checkpoint`,迁移 0004;读池上限见 (#29)。未合入 main。
Author
Owner

已合入 origin/main 0c9b459。落地提交 6a65ab8 fix: 按完成时刻分批清理并限制读连接池 (#34)。迁移 0004 与 U-02 的 0003 均在。

已合入 origin/main `0c9b459`。落地提交 `6a65ab8` fix: 按完成时刻分批清理并限制读连接池 (#34)。迁移 0004 与 U-02 的 0003 均在。
Sign in to join this conversation.