跳转至

Mailbox 拉取游标错误映射诊断

Bug 摘要

个人邮箱已经在存储层识别裁剪游标,但 MailboxNode 没有把该结果编码为下行协议 ERROR{code=CURSOR_EXPIRED}。请求任务仅记录警告后结束,Gateway 与客户端都收不到 响应,因此 SDK 无法进入既有的 REBUILD 流程。处于启动回放或 MailboxDirty 状态时也 存在同一类缺陷:虽然发出了 ERROR,但错误码仍是 protobuf 默认值 UNKNOWN,且发送 失败被静默忽略。

已验证假设

  1. 假设:Redis RangeScan 对早于裁剪水位的游标有可区分的错误。
  2. 证据:qim-store/src/redis/store.rs 在 after_seq < user_trim_seq 时返回 StoreError::CursorExpired { trim_watermark }。
  3. 结果:确认。
  4. 假设:该错误在 MailboxNode 被转成协议响应。
  5. 证据:qim-mailbox/src/pull.rs 对 range_scan(...).await 使用 ?;调用方 qim-mailbox/src/net.rs 只记录“邮箱拉取失败”。两处均没有构造 RpcKind::Error。
  6. 结果:否定。
  7. 假设:客户端已有消费 CURSOR_EXPIRED 的恢复路径。
  8. 证据:qim-sdk/src/state.rs 对 ErrorCode::CursorExpired 进入 Rebuilding 并发送 PullSessionList;qim-sdk/src/wire.rs 将数值码 1 映射为该错误。
  9. 结果:确认;缺口仅在 MailboxNode 的协议边界。
  10. 假设:未 ready/dirty 已使用协议定义的错误码。
  11. 证据:qim-mailbox/src/pull.rs 构造 RpcKind::Error 时未设置 error_code,默认值为 0;而 qim-proto/proto/qim/common.proto 已定义 ERROR_CODE_MAILBOX_DIRTY = 4。
  12. 结果:否定。

根因

  • 直接原因:拉取处理器把 StoreError 作为内部 anyhow::Result 向上传播,而网络层把 它当作仅供日志记录的任务错误,未产生请求级响应。
  • 根本原因:存储错误到稳定协议错误码的适配缺失;同一处的错误帧使用 .ok(),将断开 或拥塞导致的响应发送失败吞掉,违反错误响应与请求同等可靠的边界契约。

最小修复

在 pull.rs 集中构造并可靠发送错误帧:

  • StoreError::CursorExpired 映射为协议常量 ErrorCode::CursorExpired(数值 1);
  • 非法客户端 mailbox 序列映射为 ErrorCode::CursorInvalid(数值 3);
  • 未 ready、dirty、Redis 后端不可用或存储状态不安全映射为 ErrorCode::MailboxDirty(数值 4),并给客户端稳定、非后端实现细节的说明;
  • 发送 ERROR 使用 send(...).await 并向连接任务返回断开错误,绝不使用 .ok();
  • 上述错误路径返回前不发送 MailboxBatch,因此不会推进客户端游标。

新增回归测试验证裁剪错误、未 ready/dirty 错误及后端错误各自产生唯一的协议错误帧,且 没有成功批次。SDK 对 code 4 的具体重试策略由并行负责方处理,不在本修复范围内。

影响文件

  • crates/qim-mailbox/src/pull.rs
  • crates/qim-mailbox/src/net.rs(保留其仅处理连接级发送失败的职责)
  • docs/designs/diagnosis_20260823_mailbox_cursor_error_mapping.md

回归风险

低。成功拉取路径与存储语义不变;变更仅将此前无响应或码为 UNKNOWN 的失败显式化。 客户端会首次接收到正确错误码,因而必须确认 SDK 的 CURSOR_EXPIRED 重建链路及并行中的 MAILBOX_DIRTY 处理均通过。内部响应连接已关闭时仍会结束该请求任务,这是唯一无法向已 断开客户端交付响应的情形。