编号: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 的消息侧部分:
ExpireOnce
PurgeOnce
0003
idx_receipts_created(created_at)
idx_send_keys_created(created_at)
WHERE +state='completed' AND created_at < ?
idx_messages_created
PRAGMA wal_checkpoint(TRUNCATE)
PRAGMA optimize
OnDisconnect
AND endpoint_id = ?
idx_deliveries_outbox
dispatchFullTx
online_since
expire_at = now + grace
internal/app/message/recover.go、session.go、dispatch.go;internal/store/migrations/0003_*.sql。
internal/app/message/recover.go
session.go
dispatch.go
internal/store/migrations/0003_*.sql
completed_at
SCAN receipts
SCAN sk
messages
以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。
CleanupOnce
idx_messages_due (state=?)
recover.go:43-116
Queue.Do
recover.go:44-50
recover.go:81-114
serve.go:339
wal_checkpoint
cmd/nixmsg/serve.go
internal/store/queue.go
finalizeMessageTx
tryFinalizeTx
recover.go:81
if RecordRetentionDays > 0
identity/lifecycle.go:601-624
group/void.go:132-150
WHERE state='pending' AND pushed_conn=?
idx_deliveries_expire (state=?)
session.go:63-66
internal/app/message/session.go
appUplink.OnDisconnect
msg.OnDisconnect
expire_at
expire_at IS NOT NULL
dispatched
uplink.go:56-61
dispatch.go:148
recover.go:49
offline_since
cmd/nixmsg/uplink.go
internal/app/message/dispatch.go
recover.go
复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。
4059a15
C-03 已在 feat/fix-message-c01-c03 提交 a4e51ec5cd (#34)
feat/fix-message-c01-c03
a4e51ec5cd
completed_at 分批清理 + wal_checkpoint,迁移 0004;读池上限见 (#29)。未合入 main。
已合入 origin/main 0c9b459。落地提交 6a65ab8 fix: 按完成时刻分批清理并限制读连接池 (#34)。迁移 0004 与 U-02 的 0003 均在。
0c9b459
6a65ab8
No dependencies set.
The note is not visible to the blocked user.
编号: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 的消息侧部分:
ExpireOnce每秒跑,每个写操作最多处理 500 条,循环到做完或用完时间预算;PurgeOnce每小时跑,三类删除(过期记录、回执、防重行)各自独立成写操作、分批执行(每批几千行;先按批删投递再删消息,避免 1000 人群的级联一次删 500 万行)。(审查 M-05)0003,建idx_receipts_created(created_at)、idx_send_keys_created(created_at);记录清理改写成WHERE +state='completed' AND created_at < ?,走idx_messages_created。(审查 M-05)PRAGMA wal_checkpoint(TRUNCATE),可顺带PRAGMA optimize。(审查 M-05)OnDisconnect清推送标记的 UPDATE 加AND endpoint_id = ?,走idx_deliveries_outbox。(审查 M-09)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。与其他问题的交互 / 冲突说明
completed_at用 0005,或与本条合并为 0003)。验收与测试
SCAN receipts、SCAN sk(按 modernc 实际驱动复核)。messages表没有 completed 行。idx_deliveries_outbox。问题明细(各区审查原文,证据含文件与行号)
[M-05] 每小时一次的保留清理被放进每秒循环:全表扫描、单事务大删除、没有 wal_checkpoint
CleanupOnce每秒执行一次,而且在同一个写操作里(单写 goroutine)同时做三件事:到期投递、删过期记录、删回执和防重行。SCAN receipts(全表扫描)。SCAN sk,外加每行一次子查询。idx_messages_due (state=?),等于扫描全部已完成的消息。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。PRAGMA wal_checkpoint(TRUNCATE)」。ExpireOnce每秒跑,每个写操作最多处理 500 条,循环到做完或用完时间预算;PurgeOnce每小时跑,三类删除各自独立成写操作、分批执行。0003:建idx_receipts_created(created_at)和idx_send_keys_created(created_at)。记录清理改写成WHERE +state='completed' AND created_at < ?,让它走idx_messages_created。级联问题的处理:先按批删投递,或者缩小每批消息数。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(平台线/共享文件)。SCAN receipts、SCAN sk。[M-07] 记录保留天数设为 0 时,经 identity/group 路径完成的消息永远不删
finalizeMessageTx在保留 0 天时会删除消息行。tryFinalizeTx,以及 group/identity 完成 scheduled 消息的路径都不删行。CleanupOnce在保留天数为 0 时直接跳过删除,于是这些 completed 记录连同它们的投递行、防重行永久残留,后台还能看到。recover.go:81(if RecordRetentionDays > 0);identity/lifecycle.go:601-624;group/void.go:132-150。internal/app/message/recover.go。PurgeOnce放在一起做。messages表中没有 completed 行。[M-09] 断线清推送标记的 SQL 没带编号条件,每次断线扫描全库所有 pending 投递
OnDisconnect最后一条 UPDATE 的条件是WHERE state='pending' AND pushed_conn=?,执行计划是idx_deliveries_expire (state=?),也就是遍历所有 pending 投递。session.go:63-66;执行计划已核实。AND endpoint_id = ?,改走idx_deliveries_outbox。连接代号本身就只属于一个编号,语义不变。internal/app/message/session.go。idx_deliveries_outbox;已有的断线用例继续通过。[M-10] 断线写库与分发之间有竞态,会产生永不过期的「僵尸」不保留投递
appUplink.OnDisconnect先调用msg.OnDisconnect(入队写操作并等待提交),然后才从连接表里移除这个连接。expire_at为空的不保留投递。expire_at IS NOT NULL的投递,所以它永远不会被丢弃。接收端不再上线的话,消息永远停在dispatched,正文永不删除,还占着双方配额。uplink.go:56-61(先写库、后移除);dispatch.go:148(事务内查内存连接表);recover.go:49(清理条件)。OnDisconnect先计算 isCurrent,再从连接表移除,最后调msg.OnDisconnect。会话层写offline_since发生在此之前,所以之后的分发能按新的离线时刻正确计算宽限。dispatchFullTx的离线分支同时读online_since:如果数据库里仍显示在线,就按「刚断线」处理,expire_at = now + grace。expire_at为空的投递补上宽限截止时间。cmd/nixmsg/uplink.go;internal/app/message/dispatch.go、recover.go。OnDisconnect,随后提交一条不保留消息,再移除连接;运行兜底后投递得到宽限,宽限过后变为 dropped。复审基线:main
4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。C-03 已在
feat/fix-message-c01-c03提交a4e51ec5cd(#34)completed_at分批清理 +wal_checkpoint,迁移 0004;读池上限见 (#29)。未合入 main。已合入 origin/main
0c9b459。落地提交6a65ab8fix: 按完成时刻分批清理并限制读连接池 (#34)。迁移 0004 与 U-02 的 0003 均在。