SDK MAILBOX_BATCH 内存去重与持久化原子性诊断¶
日期:2026-08-23
缺陷摘要¶
MAILBOX_BATCH 的事件在 SQLite apply_batch 事务开始前即被写入进程内
DedupSet。若事务失败,设备游标保持原位是正确的,但同一 Machine 重连后
重放该批时会把所有条目误判为内存重复;随后空批仍会由 SQLite 原子提交游标,
导致未持久化的本地消息被永久跳过。
已验证假设¶
- 假设:状态机在持久化前改变内存去重状态。
- 证据:
state.rs::Machine::handle(MailboxBatch)调用accept;accept直接 调用DedupSet::dedup,其Accepted分支会插入键。 - 结果:成立。
- 假设:
apply_batch失败后游标没有推进,但同一状态机会继续用于重连。 - 证据:
client.rs::exec(Effect::PersistBatch)在apply_batch出错时直接返回Terminal::Retryable,仅成功后才调用Machine::commit_batch;外层run保留 同一Machine进入重连循环。 - 结果:成立。
- 假设:空的重放批仍可提交游标。
- 证据:
sqlite.rs::SqliteStore::apply_batch无条件在同一事务写local_cursor;它允许batch.events为空。 - 结果:成立。这正使“游标未在第一次失败时推进”不足以避免数据丢失。
根因¶
近因是 accept 把 DedupSet 当作持久化前过滤器。根因是内存缓存被赋予了比
SQLite local_dedup 更早的权威性,但它没有与消息和游标处于同一事务,也没有
在失败时可用的回滚机制。
local_dedup 与 local_cursor 才是可恢复的权威状态;进程内去重只能反映已经
成功持久化的条目。
同一提前提交错误也存在于 pending 对账:旧 accept 在 apply_batch 前移除
self.pending。第一次事务失败后,local_pending 仍在 SQLite,但重放不再产生
ResolvePending,因此 pending 会持续卡住。
最小修复¶
accept不再以进程内DedupSet过滤待写事件,仍在该阶段先做 pending 对账。- SQLite/MemoryStore 的既有持久双键去重继续在写事务内决定实际新增事件。
apply_batch或apply_push成功后,才由状态机把该次已成功提交的条目记入 内存DedupSet;失败路径不改变该集合。- 回归用例模拟第一次批次持久化失败(不调用
commit_batch)后重放,断言第二次PersistBatch仍携带原始事件;旧实现会得到空事件集。 - 同一提交点也约束 pending 对账:匹配
client_message_id只在批次/推送成功 持久化后才产出ResolvePending,并仅在LocalStore::resolve_pending成功后 从内存 pending 移除。否则一次apply_batch失败会让重放缺少确认副作用,local_pending永久卡住。 batch_dedup_observed_at_ms只保存尚待提交批的缓存观察时间;apply_batch失败立即移除对应键,断连时清空全部未提交键,避免不同失败批在长存活 Machine 中无界积累。成功批仍在commit_batch时读取并删除该键。
影响文件¶
crates/qim-sdk/src/state.rscrates/qim-sdk/src/client.rscrates/qim-sdk/tests/invariants.rs
回归风险¶
- 已持久化的重复条目会再次抵达 SQLite,但由
local_dedup的三元组和message_id唯一键幂等吸收;代价是一次安全的本地事务,而非静默跳过。 PUSH_EVENTS也必须在apply_push成功后才更新内存去重,否则一次推送落库 失败后接续的邮箱重放仍可重现同类问题。- pending 确认会比旧实现晚一个成功的本地事务;这是刻意的原子性边界。失败时保留 pending 会至多触发幂等重发,不会让已提交的本地消息永久显示“发送中”。
- 不修改 SQLite 的消息/去重/游标同事务边界,因而不改变已存在的本地库兼容性。
测试证据¶
- 修复前,
不变量1_落库失败后的重放仍携带未持久化事件在重放批得到0条 事件(预期1)。 - 修复前,
不变量1_批次失败重放后才确认_pending在第一次未落库后已观察到pending_count == 0(预期仍为1)。 - 修复后,两条回归与
cargo test -p qim-sdk全部通过;后者还验证消息、cursor 与local_pending在重放成功后分别为已落库、10、空集。 state::tests::落库失败或断连必须丢弃未提交批的去重观察元数据验证逐批失败 回滚和断连清空两条路径。