跳转至

SDK MAILBOX_BATCH 内存去重与持久化原子性诊断

日期:2026-08-23

缺陷摘要

MAILBOX_BATCH 的事件在 SQLite apply_batch 事务开始前即被写入进程内 DedupSet。若事务失败,设备游标保持原位是正确的,但同一 Machine 重连后 重放该批时会把所有条目误判为内存重复;随后空批仍会由 SQLite 原子提交游标, 导致未持久化的本地消息被永久跳过。

已验证假设

  1. 假设:状态机在持久化前改变内存去重状态。
  2. 证据:state.rs::Machine::handle(MailboxBatch) 调用 accept;accept 直接 调用 DedupSet::dedup,其 Accepted 分支会插入键。
  3. 结果:成立。
  4. 假设:apply_batch 失败后游标没有推进,但同一状态机会继续用于重连。
  5. 证据:client.rs::exec(Effect::PersistBatch) 在 apply_batch 出错时直接返回 Terminal::Retryable,仅成功后才调用 Machine::commit_batch;外层 run 保留 同一 Machine 进入重连循环。
  6. 结果:成立。
  7. 假设:空的重放批仍可提交游标。
  8. 证据:sqlite.rs::SqliteStore::apply_batch 无条件在同一事务写 local_cursor;它允许 batch.events 为空。
  9. 结果:成立。这正使“游标未在第一次失败时推进”不足以避免数据丢失。

根因

近因是 accept 把 DedupSet 当作持久化前过滤器。根因是内存缓存被赋予了比 SQLite local_dedup 更早的权威性,但它没有与消息和游标处于同一事务,也没有 在失败时可用的回滚机制。

local_dedup 与 local_cursor 才是可恢复的权威状态;进程内去重只能反映已经 成功持久化的条目。

同一提前提交错误也存在于 pending 对账:旧 accept 在 apply_batch 前移除 self.pending。第一次事务失败后,local_pending 仍在 SQLite,但重放不再产生 ResolvePending,因此 pending 会持续卡住。

最小修复

  1. accept 不再以进程内 DedupSet 过滤待写事件,仍在该阶段先做 pending 对账。
  2. SQLite/MemoryStore 的既有持久双键去重继续在写事务内决定实际新增事件。
  3. apply_batch 或 apply_push 成功后,才由状态机把该次已成功提交的条目记入 内存 DedupSet;失败路径不改变该集合。
  4. 回归用例模拟第一次批次持久化失败(不调用 commit_batch)后重放,断言第二次 PersistBatch 仍携带原始事件;旧实现会得到空事件集。
  5. 同一提交点也约束 pending 对账:匹配 client_message_id 只在批次/推送成功 持久化后才产出 ResolvePending,并仅在 LocalStore::resolve_pending 成功后 从内存 pending 移除。否则一次 apply_batch 失败会让重放缺少确认副作用, local_pending 永久卡住。
  6. batch_dedup_observed_at_ms 只保存尚待提交批的缓存观察时间;apply_batch 失败立即移除对应键,断连时清空全部未提交键,避免不同失败批在长存活 Machine 中无界积累。成功批仍在 commit_batch 时读取并删除该键。

影响文件

  • crates/qim-sdk/src/state.rs
  • crates/qim-sdk/src/client.rs
  • crates/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::落库失败或断连必须丢弃未提交批的去重观察元数据 验证逐批失败 回滚和断连清空两条路径。