跳转至

跨后端邮箱保留与重放边界诊断

日期:2026-08-23 状态:已实现,待完整门禁复核

缺陷摘要

MemoryMailboxStore 未执行与 Redis/Scylla 相同的 RangeScan epoch 校验和反向 区间短路,并且把自然 TTL 造成的非连续缺口写入显式 trim。Redis 另有两个独立 问题:按 max_day - retention 推导扫描下界会在未来日期桶存在时静默遗漏早桶; 同一 mailbox_seq 重放到另一日期桶时,只删除本次日期桶中的旧成员,会留下 第二份事件组。

根因与语义

created_at 决定保留日期桶,mailbox_seq 决定客户端增量顺序,二者不单调。 所以自然到期只能形成“游标可能跨过物理缺口”的独立边界,不能伪装为连续的 user_trim_seq。显式 TruncateBefore 才能推进标量 trim。

同一事件组的 created_at 来自同一份 GroupDispatch.committed_at,因此共享校验 要求事件组不跨日期桶,并拒绝重复 event_ordinal。这不是为未知 ordinal 定义映射, 只保证三种后端不会接受不具确定性的组。

V4 Redis 身份与活动日期索引

mbmeta.retention_format_version 升级为 4。每个 (user, epoch) 有一个同槽 mbseqbucket zset:member 是 16 位完整 mailbox_seq,score 是对应日桶的固定 绝对过期秒。Lua 用 Redis TIME 在写入前清理已到期 member;因此索引只覆盖仍可 物理存在的事件组,不产生每 sequence 一个 Redis key,也不在 mbmeta hash 中 永久积累 field。该 key 的 EXPIREAT 永远不早于活动 identity 的最大绝对 expiry: 新日期首次写入可用 GT 延长,旧重放只重申相同绝对上界,不能按“当前时间 + N” 续命;检测到无 TTL 的旧 key 时只按当前 zset 最大 score 修复。

另有同槽 mbdays zset:member 是 Unix day,score 是该日固定 expiry。mbmeta 仅 保存 active_day_count、从该集合重算的 min_day/max_day、至多 31 个活动日的 bucket_max_seq/bucket_expires_at、以及单一持久 expired_gap_boundary。Promote 在 读前(以及读取日桶后再次)原子枚举已到期 mbdays member,将其最大序号合并到 gap boundary,删除相应 hash proof,并重算活动范围;空 mbdays 直接删除。故长期 活跃用户的保留元数据与集合都是有界的,历史到期日不会使 min_day/max_day 永久扩张。 两份 Lua helper 均采用两阶段:第一阶段只验证所有 expired day/proof/index 并计算 boundary/remaining,第二阶段才统一 HSET/HDEL/ZREM;因此遇到后续损坏日时不会留下 “前面 proof 已删、gap 尚未完整提交”的部分状态。 冷用户的 future/跨度等预校验在 V4 hash 初始化之前执行,被拒绝输入不会制造空 mbmeta。极端情况下,长期没有读写的冷用户仍可暂存至多 31 个已过期活动 proof; 这保持有界且不为 mbdays 增加独立 TTL(否则会在 Promote 前丢失可证明 gap)。 后台巡检可调用同一 Promote 路径主动压缩这些冷用户的残余 proof。

同日的异值重放会原子删除该 score 的完整事件组后重写;日期不同则因 index score 不同返回冲突。V4 meta 存在而整个 index key 丢失、且任一 bucket proof 尚未到期时, 写入返回损坏错误并要求从分发日志重放。seqbucket_max_seq 还使“已观察序号及以下 但 identity member 缺失”的重放失败闭合。该判据依赖 materializer 对同一 shard 按 source record 顺序观察;它故意以可见性阻塞替代跨桶静默重复。

过期日志重放先查询仍活动 identity:若同一 seq 已属于另一 active day,仍返回跨日 冲突;identity 缺失时才不写邮箱 zset/index、只把 seq 合并进持久 gap boundary。V2/V3 都不含 V4 的活动集合与压缩原子关系,V2/V3→V4 append/scan 一律 fail closed;部署 必须离线迁移或从分发日志重放,禁止滚动混写。

有界扫描

RangeScan 从活动集合重算后的真实 min_day 扫到 max_day,而不是使用 max_day - retention 截掉早桶。写入会拒绝超过当前 Redis day + 30 天的 future created_at,也拒绝使活动日集合跨度超过 30 天的组合;因此没有“先接受、以后 静默漏读”的状态。ZRANGEBYSCORE 管道返回后还会再次原子 Promote:若其间自然 过期或有旧日志重放增加 gap proof,立即返回 CursorExpired/CorruptEntry,绝不 把 covered 声称为 up_to。

风险与回归

  • V4 的 seqbucket_max_seq 对身份缺失采用失败闭合;恢复方式是既有分发日志重放, 不是接受可能跨桶的补写。
  • 旧格式元数据及损坏的 bucket proof 都不能安全解释,保持 CorruptEntry。
  • 回归覆盖:Memory epoch/反向区间、自然 TTL 与显式 trim 分离、重复 ordinal;Redis 同桶替换、跨日冲突(包括过期输入)、identity TTL、活动 proof 压缩、32 天连续 桶/长期离线恢复、检查—读取间到期向量、future 窗口、V3 拒绝及过期 cursor 缺口。