Q-IM 项目记忆¶
更新日期:2026-09-28
当前阶段¶
- writer 持久提交 + Redpanda Outbox/Fanout/dispatch 日志 + MailboxMaterializer 主链路与完整客户端已落地;全工作区 fmt/check/clippy/test、隔离 standalone Redis/Redpanda 真实集成、功能 smoke 15/15、Scylla RF3 三节点契约、Redis Cluster 负向启动与 writer SIGKILL 恢复门禁均已真实通过。目标硬件 180 秒稳态 + T-HEAVY、混沌和 orphan 独立审计尚未完成,当前不得沿用旧 TCP dispatch 的容量数字或声称可发布。
- 历史 TCP dispatch 架构曾实测单节点持续容量 15,000 msg/s(180 s、P99 116 ms);该数字不适用于当前 Redpanda 日志驱动链路,禁止作为容量或发布结论。当前架构必须在目标硬件以显式审计速率重跑 180 s + T-HEAVY。
- 历史 Redis/TCP 架构的 180 s 长尾教训仍有效,但数值不可复用:幂等键一消息一个,Redis 主/expires dict 随消息速率涨,2^20/2^21 翻倍 rehash 曾把 P99 推到 727 ms;预扩后同负载 116 ms。它证明短跑会掩盖时长依赖问题,也要求当前链路保留分段指标与 180 s 门禁,不证明当前容量。
- 长尾显形在 writer→mailbox 内核缓冲,不在物化(2026-08-15 复核):rehash 期间物化健康(P99 49 ms),但 mailbox serve 读循环偶发停摆约 1 s,积压全滞留接收缓冲——无指标的隐形队列,
write_all不阻塞(缓冲远未满)故 writer 侧看着正常。实测dispatch_transitP99 848 ms、>500 ms 有 8.6 万样本,其余每段 >100 ms 计数全 0;预扩 dict 后 >500 ms 归零、端到端 208 ms 达标(同一 loadgen,只改服务端状态)。 - 历史教训:
SEND_ACK健康 ≠ 链路健康。旧实现的 ACK 不经过 writer→mailbox 段,曾导致 ACK 绿而投递长尾/丢失;新实现虽已把 ACK 收紧到COMMITTED,端到端门禁仍必须独立观测 Outbox、Fanout、dispatch 物化和客户端拉取,禁止用 ACK 单点代替链路健康。 - 可丢弃出站的消费端必须批量化:session 曾逐条 await 投影 HSET(吞吐≈1/RTT),15k 下 75% 投影被丢弃;改每连接单消费者 recv_many + 单次 pipeline 后归零。
- 池化必须配显式准入(mailbox
DISPATCH_INFLIGHT_LIMIT信号量 + TCP 反压;writer 的 dispatch 满时等待不丢弃);热路径禁加 Redis 侧"保险"——Lua 版本比较曾把 15k 的 P99 推高 3 倍(教训见 projection.rs 头注释)。 - "键控投递 + 单消费者"保证的是同一个消费者,不是有序(2026-08-19 实测修复):writer 对每请求 spawn、writer→mailbox 非键控
send、mailbox 对每条 dispatch 再 spawn,所以投影入队顺序 = dispatch 完成顺序 ≠ conversation_seq 顺序。当时会话头存在投影行里,末值合并写的是"后到"的那条,会话头倒退——用户看到"会话列表里最后一条消息是倒数第二条"。低速率下更容易中:消费者被逐条唤醒,乱序的两条分属两批,末值合并根本碰不到。 - 会话头不额外写第二份:qsession 对本页会话经 qstore 两轮 Redis pipeline 读取 canonical
msghistory:{commit}:.../msgrecord:{commit}:...,会话头取最新 MessageRecord;不恢复无人写入的旧hist:{cid},也不把头塞回可能乱序的投影行或 writer 热路径。 - 热路径每消息多一次 Redis 写就见顶(2026-08-19 同机 A/B):把
SET head:{cid}加进ALLOCATE_SCRIPT(想白拿INCR cseq的串行化顺序),SEND_ACKP50 6.3→10.0 ms、端到端 P99 239→443 ms,只增删这一行。与"每收件人一次 evalsha"是同一条教训的两种形态:单线程 Redis 近饱和时,ρ 的微增就是排队的剧增。 - 投影行只留会话集合(
convs:{user}zset,成员 = hex(cid),分值 =created_at):写用ZADD ... GT——只增不减是 Redis 原生语义,零额外命令(它替代的就是原来那次写),乱序到达也不会让活跃度倒退,顺序要求不复存在而不是被防住。读路径build_snapshot仍ZREVRANGE 0 -1全量载入并对全部会话按定义式求未读,快照 5 分钟 TTL;重度用户的成本在快照重建而非分页(2026-09-25 按代码核对,此前“首页只取 N”的表述不成立)。 - 顺序要求消失后,投影入队队满时可先等 50 ms 再丢(
PROJECTION_ENQUEUE_WAIT),回退用非键控send;此前键控是必需的所以只能立刻丢。仍保留超时丢弃:消息可达性不该依赖 SessionProjection 的健康度。 - 连续水位有成本:现行
mailbox_seq来自 dispatch partition offset,W是持久化的 64-lane 连续物化状态,不再使用旧直写模型的“登记在途 + 完成销账”。当前成本来自 packed W 的读取、解码、CAS 与写回;早期“水位取最大值”版本虽测得更高吞吐,但会静默越过未完成区间,容量数字作废。 - Redpanda 提交/分发日志、客户端 REBUILD 执行器和 Redis/Scylla 双后端已实现;ScyllaDB 在隔离三节点、RF=3、LOCAL_QUORUM、Tablets 禁用环境下通过真实 CQL/LWT/TTL/GC 契约门禁。PresenceDirectory、NotificationService、WebSocket 承载、帧压缩分片、E2EE 仍未实现。
- 总体设计基线
docs/PLAN.md(v2,已合入评审 001 的 84 条、发布前终审 46 条、大群能力补漏 10 条)。 - 评审记录
docs/REVIEW-001-plan-baseline.md;重大决策docs/adr/。 - 后续设计与实现必须先参考上述文件;核心决策变化时同步更新本文件和
docs/PLAN.md。
契约核心(不得被任何专题文档重新定义)¶
docs/PLAN.md§5.1 实体与命名表docs/PLAN.md§6 标识、序列与游标docs/PLAN.md§7 核心数据模型docs/PLAN.md附录 A 协议帧与错误码总表docs/PLAN.md附录 B 默认参数表
改上述内容:先改 docs/PLAN.md 或新增 ADR,再改实现。
已锁定架构决策¶
接入与连接¶
- 客户端实时主链路 TCP/TLS 自定义二进制协议;浏览器用 WebSocket 承载同协议。
- 端口固定 443,TLS 1.3 ALPN 协商
qim/1与http/1.1;回落序 ALPN → WSS 443 → 代理 CONNECT。 - HTTP 只用于媒体上传下载、管理后台等非实时场景。
- 用户经稳定虚拟桶固定到逻辑 MailboxShard 和 ConnectionShard;虚拟桶数建集群后不可变。
session_epoch由 Auth/Session 服务分配,(tenant,user,device)维度单调递增;ConnectionNode 只校验不生成。- 在线位置由 PresenceDirectory 承载,经 compacted topic 按 user_bucket 分区发布,推送路径 0 次同步远程调用。
- 心跳自适应:起始 60 秒,前台上限 120 秒,后台上限 240 秒;服务端经 PONG 下发间隔与超时。
- 发送侧独立超时:3 秒无 SEND_ACK 触发 probe,端到端不可用检测 ≤ 7 秒。
- 帧头固定 20 字节大端;
header_crc只覆盖帧头,magic/crc 失配立即断连、禁止重新同步(docs/01 §2.1)。 - 存储层 ID 映射:u128 用
blob大端 16 字节,u64 用bigint且必须断言 bit63 恒零(否则跨 2^63 聚簇排序翻转)。 message_record表级 TTL 必须为 0,保留期按retention_class逐行USING TTL;否则compliance_hold被静默删除。- 所有 MessageStore 表禁止显式 DELETE,删除走 TTL 或加密擦除(TWCS 下范围墓碑不释放空间且击穿 tombstones_scanned==0 断言)。
- 历史翻页必须有
history_max_empty_bucket_scan上限——conversation_seq的合法空洞会造成连续空桶扫描。 - 载荷编码 Protobuf + 正文
bytes透传;决定性理由是 §27.1.2 要求未知字段按 tag-length 跳过并保留原始字节。 prost默认不保留未知字段:服务端不得对客户端上行结构做"解析后重写",上下行两套 message(docs/01 §3.3)。- 帧层
NEED_ACK保留不用——帧层 ACK 会与游标+幂等重复,同拒绝 MQTT QoS 之理由。 - 多路复用必须按
stream_write_chunk_bytes(64 KiB) 分片写出,否则 4 MiB 大帧阻塞 PONG 致心跳误断(docs/01 §5.2)。
序列与游标¶
- 五种序列:
message_id、conversation_seq、last_activity_id、mailbox_seq、room_seq。 message_id与last_activity_id同为 u128 同源 HLC,同 ID 空间可比较。conversation_seq单会话严格递增但允许空洞(预留窗口、治理删除、可见性裁剪)。mailbox_seq为(shard_epoch:16, log_offset:48)复合 u64,跨 epoch 单调不回退。materialized_watermark按 lane 分组为向量;lane_id = blake3(tenant_id, user_id)[0] & (lane_count-1),独立哈希、不从 user_bucket 推导(§6.5.1),故分片分裂不改任何用户的 lane 归属。mailbox_trim_watermark是"空洞可安全跳过"规则的前置条件。- 设备游标只能由
MAILBOX_BATCH连续推进,实时PUSH_EVENTS不得越位推进游标。 - 每设备独立保存邮箱游标;用户级已读状态跨设备共享。
邮箱与分发¶
- 每用户有独立邮箱键空间;邮箱只存消息引用,不复制群消息正文。
- 同群消息在同一 MailboxShard 只分配一个
mailbox_seq;用户队列稀疏但按前缀精准查询。 - 事件组硬上限:单个
(user_id, mailbox_seq)至多 8 条、8 KiB,且每种event_type至多 1 条(当前 4 种类型下至多 4 条,8 为未来预留);批次切分只能在mailbox_seq边界。 - fanout 重试永不丢弃已 COMMITTED 的消息;
fanout_retry_max_window只界定 UI 倒挂窗口,非丢弃阈值。 event_id为确定性哈希,禁随机 UUID;所有参与物化的输入必须确定性。- 服务端登录时只在
AUTH_OK告知存在离线数据,由客户端批量拉取,不逐条推送离线消息。 - 群消息正文只存一份;10 万成员全量离线可达需 10 万条轻量邮箱引用。小会话(
N × body_size ≤ 8 KiB)例外,正文内联进邮箱条目(ADR-0005)。 - 分片归属唯一权威是 ShardRegistry;日志消费用
assign()静态指派,禁消费者组自动 rebalance。 - MailboxNode 接管起始位点 =
min_j(W[j])的 offset + 1,不取消费者组已提交位点。 - 邮箱裁剪两类:常规时间窗到期与超
max_mailbox_entries_per_user的容量兜底;两者越过 落后设备游标时都必须显式CURSOR_EXPIRED,区别是后者触发即 P2,前者不因正常到期告警。 - 表情回应是第三类投递语义
ephemeral_aggregate:不进邮箱、不计未读、不改排序、不触发离线推送;持久的是聚合MessageReactionSummary(§13.6)。 - 成员数超
membership_event_broadcast_max_members(500)时,成员变更禁向其他成员广播,只在PULL_MEMBERS体现。 - 10 万成员的成员列表必须分页拉取,
@补全走服务端前缀搜索。 - Telegram 超级群的 per-channel 游标 == 本文
mention_only档,差别只在默认值与承诺(§10.2.4)。 mailbox_write_policy默认always;mention_only降级档为预留能力,启用需 ADR-0003。mention_only准入判据是 α 判据(活跃比例),非单一人数阈值;big_group_lazy_threshold是反推的等效值,待实测回填。- 读扩散档的历史缺口由
SESSION_LIST_BATCH.write_policy+delivered_conversation_seq显式告知,是"邮箱层唯一锚点"的唯一例外。 - 在线推送前必须先可靠物化邮箱引用,推送失败经邮箱恢复。
- 加群不回填历史邮箱引用;以
(joined_at, left_at)序号区间为边界(ADR-0021 已实现): 入群joined_at = J−1、退群/移除left_at = J、建群初始成员joined_at = 0;区间外的 历史、未读与会话头一律不可见,退群者仍可读退群点之前。
会话列表¶
- 采用
ConversationHead + UserConversationState + UserSessionProjection三层模型。 UserConversationState是会话集合的持久权威;UserSessionProjection是可重建物化视图。- 普通消息不在发送链路中同步更新所有离线成员的持久会话投影。
- 投影压缩器由邮箱写入流尾部驱动 +
dirty_usersbitmap,禁按用户主键全表扫描。 SESSION_DELTA是幂等绝对值帧,版本源为projection_mailbox_seq,禁增量语义。- 未读的权威定义式基于
read_conversation_seq水位;unread_count只是缓存,冲突以定义式为准。 - 全局角标由
UserBadgeState提供绝对值,与投影同 WriteBatch、同检查点。
其他¶
- 普通群保留个人邮箱和按成员区间过滤的历史(ADR-0021)。聊天室只用房间序列、实时广播及短期回放。
- 图片视频原文件进对象存储,IM 消息只存缩略图及对象引用。
- 投递至少一次语义,客户端按
message_id/event_id幂等去重。 MailboxStore三级演进:一期 Redis(ADR-0007)→ 阶段一 ScyllaDB → 阶段二 自研 LSM(rust-rocksdb,ADR-0001 四条判据之一成立时)。- E2EE 适用单聊与 ≤1000 人群;超大群与聊天室不支持。删除依赖加密擦除。
- 对照评审吸收(OpenIM,2026-08-15):①同设备重复登录顶号(KICKED{REPLACED} + 主动断开,一期 user/device 合一按 uid 顶);②发送前审核回调(HTTP 状态码协议 200/403,超时
moderation_sync_timeout_ms按 QIM_WEBHOOK_FAIL_POLICY 走 fail-open/closed)与发送后异步通知(有界队列可丢有计数);③会话列表未读改单次 pipeline 求值(对照 GetMaxSeqs,mention_only档的 delivered_seq 批量拉取复用);④message_record主键即幂等的对照注记(docs/02 §2.1)。 - E2EE 存储模型已裁决:单份正文密文 +
E2EE_KEY_DIST逐设备密钥分发(sender-key),不做逐设备正文副本。 - 登录对账与幂等窗口(ADR-0008,2026-08-15):仅发送者本人的邮箱条目回带
client_message_id(§7.3 既有要求,用作对账锚点;其他收件人不带);断线重连后重发任何 local_pending 前必须先同步邮箱对账、未命中才重发;client_dedup_ttl_seconds24 h → 2 h,与client_pending_max_age(保持 24 h)解除恒等。理由:幂等键每消息一个,是一期 Redis 键数量主导项,窗口长度直接决定稳态键总量与 dict 翻倍规模;窗口只需覆盖"已提交但对账时尚不可见"的暴露期(水位停滞运营时限 1 h 的 2 倍)。存储条目编码在尾部追加client_message_id:16(全零=无),旧条目免迁移。
未读与已读(2026-08-17)¶
- 未读只有一个定义式:
|{m | m.seq > read_conversation_seq ∧ m.sender_id ≠ 自己}|(§12.5.1)。服务端与客户端用同一式子——定义一分叉,"为什么这台显示 3 那台显示 5"就无从查。禁latest - read相减(conversation_seq允许空洞,相减把治理删除算成未读,角标永远清不掉)。 - 定义式有两个权威输入,缺一个就出假红点(2026-08-19 实测修复):
①
sender_id ≠ 自己——漏了它,自己发的消息算成自己的未读;"发送即已读"只是让它在本地水位在手时不显形,水位一丢(换设备/清缓存/GUI 内存库重登)未读数就恰好等于自己上次发出去的条数。故LocalStore::open(self_user_id):本地库要算未读就得知道自己是谁。 ②read_conversation_seq——user 级、只存在服务端(read:{user})。协议原先没有任何下行通道,客户端只能从 0 起算 base。现SESSION_LIST_BATCH.sessions[]回带该字段,且登录序列自动拉一次会话列表取回(不能等宿主开口)。合入只进不退;本地更高时反向回补MARK_READ,两侧收敛。 - 服务端会话页从 canonical history 有界读取定义式未读:精确窗口为
latest_seq - read_seq <= 200,解码后排除自己、Control、Recalled/Deleted;超过窗口返回饱和值 200 并在内部标记unread_exact=false。当前 Session RPC 尚未公开该精确性位且仍固定 legacy tenant 0,均为后续兼容升级项;任何所需索引/记录缺失或损坏必须整页失败,不能伪装成零未读。 - 同一批新消息,SDK 先发
ConversationsUpdated(已含这批)再发MessagesAdded。上层在后者里再 +1 就是同一条数两遍(GUI 踩过)。未读增量归前一个事件全权负责,上层只覆盖。 - 未读在读路径算,不在写路径算(
projection.rs头注释)。给SESSION_DELTA带unread_count就要在写路径按定义式求值,15k msg/s 下 +30,000 次存储往返/秒(约 +40% 存储负载),且落在刚定位过塌陷的那条路径。结论:不做。 - 客户端本地算未读:本地消息 + 本地水位,同一定义式,零服务端往返。同步完成前是下界(本地是全集子集),只会少报不会多报。
- 送达 ≠ 已读:后台设备、锁屏、最小化都会送达但不该清未读。判定"会话在前台"是 UI 职责,SDK 只提供
MarkRead原语。例外是"发送即已读"——发送是主动行为,不标则服务端定义式把自己发的算成自己未读(实测踩到)。 - 已读水位是 user 级、跨设备共享(
read:{user},不带 device);邮箱游标才是 device 级。"别的设备拉离线消息会重新标未读"不成立。 READ_SYNC(0x0406)契约已定、故意不实现:一期evict_device按 uid 顶号(user/device 合一),同用户不可能两设备同时在线,此帧永远没有接收方。前置条件是多设备本身(连接注册改(uid, device_id)、顶号语义改"顶同设备"),见docs/11§8.1。- 已读回执(谁读了)尚未设计。关键洞察:不需 per-message 存储——已读是水位,有前缀性质,"谁读了 seq=50" = {成员 | 水位 ≥ 50},存储量 O(成员数) 而非 O(消息数 × 成员数)。但大群必须"不推只拉"(1000 人群全员已读 = 10^6 帧,是 N²),隐私必须对称(关掉自己回执就不该能看别人),E2EE 下是元数据泄露。
术语约束¶
message_id:全局唯一、时间有序的消息标识(u128,HLC)。conversation_seq:单会话严格递增的历史顺序,允许空洞。last_activity_id:会话列表与跨会话时间轴排序键,与message_id同源同格式。mailbox_seq:MailboxShard 的复合投递序号,个人队列允许空洞。room_seq:聊天室短期顺序。lane_id:MailboxShard 内的可见性隔离通道。materialized_watermark[lane]:该 lane 已完整物化并可安全暴露给客户端的连续水位。mailbox_trim_watermark:已物理删除的最大mailbox_seq。MailboxCursor:设备已应用完成的邮箱位置,≠ 网络已发送位置。ConversationHead:每条消息只更新一次的会话公共头。UserConversationState:置顶、静音、已读、隐藏和成员关系等用户主动状态。UserSessionProjection:从用户邮箱派生并可重建的会话列表物化视图。UserBadgeState:用户级全局未读聚合,供离线推送角标使用。PresenceEntry:per-device 在线位置条目。
实体命名(禁止别名)¶
ConnectionNode、MailboxNode、ConversationWriter、FanoutCoordinator、HistoryArchiver、MessageStore、 GroupMembership、ShardRegistry、PresenceDirectory、SessionProjection、RoomWriter、 MediaService、NotificationService、ModerationService。
禁止出现:MessageNode、Mailbox Node(带空格)、Message Store(带空格)、connection_epoch。
禁止方案¶
- 禁让用户扫描 MailboxShard 分发日志找自己的消息。
- 禁登录时逐个会话拉离线消息(不含 §10.2 的大群读扩散降级档)。
- 禁为超出
inline_body_budget_bytes(8 KiB 乘积预算)的会话复制完整正文;小会话正文内联见 ADR-0005。 - 禁每条群消息同步更新所有成员的持久会话记录。
- 禁把 HTTP 当 IM 实时通信和同步主链路。
- 禁把图片视频等原始大文件直接写进消息正文存储。
- 禁宣称 exactly-once;统一至少一次投递加幂等。
- 禁用物理节点数量直接取模分配用户,必须经稳定虚拟桶。
- 禁在早期邮箱任务未完成时越过所属 lane 的连续物化水位推进客户端游标。
- 禁把客户端时间戳当任何增量同步的下界(晚到消息会永久丢失)。
- 禁用
conversation_seq或mailbox_seq的差值判定丢消息(两者对用户视角天然稀疏)。 - 禁把分片级
materialized_watermark与个人游标直接比较(会造成全网空拉)。 - 禁
SESSION_DELTA用增量语义。 - 禁在物化路径用本地墙钟或随机 ID。
默认技术候选¶
- Redis:一期 Redis 7.0.0+ standalone MailboxStore(按天分桶 zset,7 天固定绝对到期(ADR-0023,原 30 天);AOF +
noeviction,Redis Cluster 不支持)。 - MessageStore 一期为分层形态(ADR-0018):Redis 承载提交状态与 7 天热窗口历史,
qim-archiver异步批量归档到 ScyllaDB;ScyllaDB 是永久历史的权威。 - ScyllaDB:消息历史与正文、会话公共状态、群和用户元数据、阶段一 MailboxStore。
- Redpanda:提交日志、Outbox、分片分发日志、PresenceDirectory 的 compacted topic(已选定,非 Kafka)。
- rust-rocksdb:MailboxNode 本地个人邮箱索引(阶段二,见 ADR-0001)。
- S3/MinIO:媒体、检查点及冷归档。
- RoaringBitmap(roaring-rs):群成员分片、在线成员集合、
dirty_users;持久化必须用 portable 格式。
正式实施前必须完成基准测试,技术候选非不可替换的强制依赖。
文档 Todo¶
-
[x] 编写
README.md与全部 11 份专题文档;建立 ADR 0001~0007。 -
[x] 写入总体设计计划
docs/PLAN.md。 - [x] 完成基线评审
docs/REVIEW-001-plan-baseline.md并将修订合入 v2。 - [x] 定义契约核心:命名表、五种序列、数据模型主键、帧总表、默认参数表。
- [x] 建立 ADR 0001~0007。
- [x] 编写术语×模块对照
docs/glossary-modules.md(一词多义消歧:shard/partition/bucket/epoch/offset/consumer/watermark 各自归属哪个模块;按 crate 列出术语归属;术语层面的已知缺陷)。 - [x] 编写名词解释
docs/glossary.md(系统实体、五种序列、游标与水位、数据模型、协议帧、错误码、安全加密、存储基础设施的分类解释)。 - [x] 编写消息处理流程
docs/message-flow.md(群聊/单聊完整链路、lane 水位推进机制、离线同步、REBUILD、会话列表更新、聊天室、故障恢复、端到端延迟分解、四道阻塞防线)。 - [x] 编写系统总览
docs/00-system-overview.md。 - [x] 展开 TCP/WebSocket 二进制协议细节
docs/01-connection-protocol.md(opcode 表、20 字节帧头、Protobuf + 正文透传、zstd、分片写出多路复用)。 - [x] 展开物理存储模型与 DDL
docs/02-message-model-and-storage.md(6 张表 DDL、压缩策略、seq_bucket 分页、分组批量读、加密字段)。 - [x] 展开大群分发与故障恢复
docs/03-mailbox-and-group-fanout.md。 - [x] 展开会话列表算法
docs/04-session-list.md。 - [x] 展开聊天室与控制消息
docs/05-chatroom-and-control-message.md。 - [x] 定义部署、扩缩容、租约漂移接管与跨地域容灾
docs/06-microservices-and-deployment.md。 - [x] 展开可靠性、安全与运维
docs/07-reliability-security-operations.md。 - [x] 编写测试与容量计划
docs/08-test-and-capacity-plan.md(容量表待压测回填)。 - [x] 编写离线推送与角标
docs/09-push-and-badge.md。 - [x] 编写保留、删除与合规
docs/10-retention-deletion-compliance.md。
技术栈语言约束¶
- 服务端语言已选 Rust(ADR-0006,倾向性决策,基准后终定),统一 tokio,禁混合语言部署;不会是 Java/JVM。
- 提交与分发日志用 Redpanda(非 Apache Kafka),Kafka API 兼容故不锁死(ADR-0006)。
- Redpanda 客户端使用
rust-rdkafka(librdkafka 的 C 绑定);ADR-0012已取消 Fanout Kafka 事务与transactional.id,防脑裂不再依赖ProducerFenced,而由会话头条件写与数据面fencing_epoch校验承担。 - 一期按简化形态交付(ADR-0007):群上限 1000 人、
MailboxStore用 Redis、MailboxNode 无主备、聊天室进程内环形缓冲。 - 一期全部不可变项按目标档定死,尤其
lane_count = 64(千人群用不上,但今天不做以后做不了)。 MailboxStore三级演进:一期 Redis → 阶段一 ScyllaDB → 阶段二 自研 LSM(rust-rocksdb)。- 设计文档中所有机制必须在 Rust 和 Go 两生态下均可实现,禁绑定单一语言生态的库为唯一方案(保留 Go 作 ADR-0006 复评基线)。
- §23 的 Akka 对照只借模型不借实现,不引入 JVM。
开发与文档规则¶
- 始终用简体中文写设计说明和代码注释。
- Go 用 gofmt 和 golangci-lint;优先标准库和官方维护依赖。
- Rust 用 rustfmt 和 clippy(
-D warnings);异步运行时用 tokio;禁无充分理由的unsafe。 - TypeScript/JavaScript 禁动态 import、禁转
any,用 eslint 和 prettier。 - Python 用 poetry 环境时经
poetry run执行,并用 black、isort 和 flake8。 - 新增依赖前说明选择原因、优点、缺点和替代方案。
- 核心功能、故障恢复和高风险逻辑需测试;避免低价值重复测试。
- 所有术语、字段和时序必须与
docs/PLAN.md契约核心一致。 - 文档中每个机制必须可实现、可验收;禁"建议加强""需要优化"这类无判据表述。
- 凡给数值必须与附录 B 一致;待实测参数回填前不得用于容量结论或采购决策。
代码结构与纪律¶
- Cargo workspace,服务端核心与端上共享 crate:
qim-proto(契约核心代码化:常量/ID/路由/编译期断言 + .proto)、qim-codec(帧编解码)、qim-store(MailboxStore 抽象 + Redis Lua)、qim-common(内部 RPC + 幂等 + 群成员 + 限流 + TLS +obs/可观测)、 六个主链路服务端可执行体qim-gateway/qim-writer/qim-fanout/qim-mailbox/qim-session/qim-archiver(ADR-0018), 以及qimctl(运维控制台)与qim-loadgen(验收 + 压测)。 端上:qim-sdk(一份 Rust 内核)+ 三个平行绑定qim-sdk-ffi(C ABI) /qim-sdk-uniffi(Swift/Kotlin/Python)/ wasm-bindgen(未立项), 以及qim-desktop(终端客户端)与qim-gui(Tauri 2)。 绑定层不得反向影响核心(ADR-0010 §2):核心一行不为绑定而改。qim-loadgen不得自带状态机/游标/去重副本——依赖qim-sdk。副本与 SDK 并存必漂移,漂移那侧永远不被压测执行到。 压测中 5% 连接由真实 SDK 驱动("SDK 回归泳道",--sdk-clients可调,0 = 关闭作容量对照);其余留在帧层,因 PING 对照组与 ACK/PUSH 精确时刻是分段归因前提,SDK 事件流给不出该粒度。 泳道必须配对互发:SDK 对自己发的消息走对账,回声按message_id去重后静默丢弃,自聊时"是否走完全链路回到本端"在公开面不可观测。 - 服务按职责分模块,
main.rs只做装配;单文件不超约 330 行。 契约落地点集中在少数文件,文件头注释写"为什么必须这样"而非"做了什么":gateway/backend(游标推进唯一入口)、gateway/push(不得推进游标)、writer/send(trace_id 生成方)、mailbox/dispatch(一消息一序号 / 64 lane / 内联只执行不判定)、mailbox/pull(唯一推进设备游标的路径)、session/unread(定义式未读)。 - 契约常量只在
qim-proto/src/consts.rs定义,其他 crate 一律引用,不得重复定义;新增常量必须能在docs/PLAN.md中按 snake_case 追溯(CI 门禁强制)。 - 不可变项(
VIRTUAL_BUCKET_COUNT、LANE_COUNT、MESSAGE_SEQ_BUCKET_WIDTH、epoch 位宽等)带const _: () = assert!(...)编译期断言,改错直接编译失败。 - 序号用 newtype 封装(
MailboxSeq/ConversationSeq/MessageId/LaneId),构造时强制校验 bit63 恒零——否则 CQLbigint有符号排序在跨 2^63 时翻转。
可观测性纪律¶
trace_id由 ConversationWriter 在提交时生成(§24.2 指定生成方),经 Dispatch → PushBatch/Projection 原样透传;各服务各自生成等于没有追踪。- 日志禁输出
access_token/游标签名/密钥/明文正文/预览;用qim_common::obs::{secret_digest, body_shape, text_shape}的脱敏表示。check-contract.sh拦截违规写法。 - 指标名一律
qim_前缀;标签禁含user_id/message_id等高基数字段。 - 各服务暴露
/metrics(Prometheus 文本)与/healthz(恒零指标非零返回 503,让编排系统摘掉该实例)。gateway/writer/mailbox/session 为 9100~9103,fanout 为 9106。 - 恒零指标不允许有假阳性:语义是"停止发布"。并发路径上无法本地判定的不变量(如
mailbox_seq单调)应移到启动自检,而非加会误报的运行期断言。自检判据比较log_offset而非复合序号——复合序号含 epoch 高位,空库时compose(epoch=1, 0)是 2^48 而非 0,按复合值判会把每次全新启动都误报。 - 水位是连续覆盖点,非“见过的最大 offset”:现行同一 dispatch partition 按序处理;只有某 record 的全部 lane/chunk/entries 已 durable,且
expected_previous/CAS 证明没有越过前项时,才把对应 lane 的W推进到该 record 的 observed offset。物化失败不得推进 W(停在旧覆盖点是安全失败)。W = min(在途)-1与“分配登记/完成销账”仅属于旧直写序号模型。 message_id必须按 §6.2 位布局生成(含region_id/writer_id)。缺writer_id时两 writer 实例同毫秒会生成相同 ID,客户端按它去重 → 两条消息只显示一条。writer_id默认由 Redis 租约自动分配(启动占位 + 20s 续约,identity模块),QIM_WRITER_ID显式配置仅用于有稳定序号的环境(k8s StatefulSet)。- writer 多实例:gateway 配
QIM_WRITER_ADDRS(逗号分隔),按conversation_id会合哈希亲和路由,实例断线就近回退(reader EOF 即感知,不等写失败)。路由只是亲和性优化——一期conversation_seq分配与幂等都在 Redis 原子完成,任何 writer 处理任何会话都正确,回退无需补偿;阶段一换 ShardRegistry 会话租约时整体替换路由器(gateway/state.rs::rendezvous_pick),接口不变。 ./scripts/check-contract.sh是 CI 契约断言:禁别名、禁subscribe()、禁累加型未读更新、禁显式 DELETE、禁重复定义契约常量、契约常量必须可追溯、日志脱敏。./scripts/acceptance.sh是九道门禁隔离验收(完整门禁认证hybrid):格式/lint/契约 → 测试(含历史分层门禁)→ release + 六服务(含 qim-archiver)→ 功能(15 项)→ 180 秒实际到达率/SLO → T-HEAVY → Redis 最终工件 → 不变量证据 → MessageRecord↔dispatch orphan 独立审计。外层只允许qim-it-*隔离项目与精确 PID;orphan 的 durable 事实源未完成前,完整发布验收必须失败闭合。./scripts/perf-test.sh full仅是上述权威门禁的兼容包装器,必须显式传入经审计目标速率且时长固定 180 秒;旧steady/backlog/heavy因会修改共享 Redis、全局杀进程或用代理指标冒充真实长尾而被显式拒绝。- T-HEAVY(
--heavy)判的是另一条 P0 路径:5000 会话用户的会话列表首屏,P99 ≤ 500 ms(实测 6.1 ms)。稳态压测测的是消息投递,两者瓶颈不同,稳态绿不代表首屏绿。会话必须经真实写路径造(5000 对端各发一条),直接种 Redis 会把键布局抄进测试——布局一改测试照样绿而线上已读不出。client_message_id必须每轮不同:固定值时非空库上会被幂等回放且不再 fanout,ACK 照收但消息进了上一轮的会话。 - acceptance 的性能门禁前先等机器安静:它紧跟在 clippy+test+全量 release 构建之后,构建残留把端到端 P99 抬高一倍以上(同一份二进制:空闲 179~246 ms,构建刚结束 302~416 ms)。阈值不放宽,只让数字反映代码——会随机红的门禁最后一定被当成"本来就飘"而失效。
- 提交前:
cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && ./scripts/check-contract.sh
分支¶
本仓库采用「一条主干 + 两条存储线」,见 BRANCHES.md。
功能与契约只在 main 开发,单向合并进 store/redis 与 store/scylla。
客户端缺陷修复(2026-08-16)¶
PC 端验收发现并修复两处 qim-sdk/qim-desktop 缺陷:
/quit与顶号退出后进程挂起:qim-desktop的 stdin 读取任务用tokio::io::stdin()(内部 blocking 线程池),main 返回时 Runtime drop join 该线程 → 进程永不退出(4 次复现)。修复:退出路径显式std::process::exit(0)(CLI 工具退出即终止)。- 断线期间发送不立即落
local_pending(违反docs/11§4.1 "send_message立即落库并返回"):断线时命令只在 SDK 内存队列排队,进程退出即丢,且/pending与 UI 不一致。修复:run()退避期间用select!同时消费cmd_rx,SendMessage先落库并回 Pending 事件,帧由重连后对账按原 id 重发(ADR-0008 幂等收敛);Disconnect立即终止事件循环。新增 2 条回归测试(offline_send_tests)。
端到端验证:/quit 进程正常退出;断线发送 /pending 立即可见、重连后 [已送达] 成功;顶号后旧连接正常退出。全 workspace fmt/clippy/test/check-contract 全绿。
客户端跨平台与 Windows 打包(2026-08-16)¶
qim-desktop是纯 Rust 终端客户端(tokio + clap + rusqlite bundled + rustls),无平台特定代码,Linux / macOS / Windows 三平台均可编译运行,交互命令一致。- Windows 交叉编译链路(Linux 侧,无需 Windows 机器):
zig 0.16 + cargo-zigbuild,已固化到scripts/build-windows.sh。产物target/x86_64-pc-windows-gnu/release/qim-desktop.exe(PE32+,~7.5 MB,单文件无系统依赖)。发布包qim-desktop-windows.zip(2026-08-19 重打:zig 在 /tmp 会被定期清理,每次重新下载 0.16.0 解压到/tmp/zig/zig;README-Windows.md 不在 git 里,从旧 zip 提取后zip -j平铺打包,勿带路径)。 - 交叉编译三个坑(都踩过):
- 全局
~/.cargo/config.toml的rustc-wrapper=sccache会包装 zig 的 C 编译 → 覆盖CARGO_BUILD_RUSTC_WRAPPER=为空; - zig 0.16 不认
x86_64-pc-windows-gnu(UnknownOperatingSystem),必须用x86_64-windows-gnu;cargo-zigbuild 自动处理; --target=windows-gnu的 rustc 传 GNU ld 风格 PE 选项(--dynamicbase等),zigcc/ld.lld(GNU 驱动) 都不认——只有 cargo-zigbuild 的链接参数转换能过。- 跨机器访问:gateway 默认监听 127.0.0.1,客户端连别的机器需
QIM_GATEWAY_LISTEN/PUSH、QIM_TOKEN_LISTEN设 0.0.0.0(开发环境明文,内网可用)。macOS 同理可直接cargo build --release -p qim-desktop(本机构建,交叉编译需 macOS SDK)。 - 仓库根旧
qim-server-linux-x86_64.tar.gz是 2026-08-19 TCP dispatch 历史产物,缺qim-fanout,不得作为当前发布包。新打包流程必须包含五个主链路服务与 qimctl,并经隔离八阶段门禁;禁止再用FLUSHALL、全局pkill或共享 6379 做发布准备。
GUI 客户端(Tauri 2,2026-08-16/17)¶
新增 crates/qim-gui:Tauri 2 + React/TS + Vite + qim-sdk 图形界面客户端,功能:会话列表(单聊+群)、聊天窗口、群成员管理、单聊/群消息收发。
- 布局:左栏会话列表(含加群框)、中栏聊天(消息+输入)、右栏群成员面板。
- 登录必须来自用户显式动作,宿主构造只打开稳定 SQLite 与启动 driver,不得在组件 mount 时自动联网。SDK
Online事件再触发会话初始化(拉列表+打开单聊)——不能在 login 返回后立即拉(SDK 未就绪会丢首帧应答,实测踩到)。 - SDK 线程安全增强:
Handle的cmd_tx/event_rx改用futures::lock::Mutex并让send/next_event收&self——事件泵长期阻塞next_event不阻塞命令。原&mut self时 GUI 事件泵独占 Handle,命令死锁(pump_events 持锁跨 await)。注意:std::sync::MutexGuard 跨 await 不 Send(tauri command 要求),futures Mutex 满足且 wasm 可编译(ADR-0010 可移植约束)。 - SDK 群能力:
Command::PullMembers+Event::MembersUpdated(wire/state/api/client 四层),qimctl 新增membership create(管理面建群),qim-desktop 新增/members、/gmsg验证命令。端到端验证:拉群成员、群消息收发、单聊收发全通。 - 构建:Linux 需
libwebkit2gtk-4.1-dev等(已装);tauri dev(开发,vite :1420)与tauri build --no-bundle(release 二进制)均可。 - 无头验证:Xvfb :99 + openbox(WM,否则 WebKitGTK 不接收输入)+ xdotool XTEST(
--window坐标鼠标无效,用根坐标)+ import 截图 + rapidocr OCR。 - 交付截图:
qim-gui-main.png(单聊主界面)、qim-gui-group.png(群成员)。
GUI 能力补齐(2026-08-18)¶
crates/qim-gui 补齐 SDK 公开面:聊天室、消息类型透传、终态/错误事件、发送闭环。
- 聊天室(桥接层
room_join/room_leave/send_room_message,复用 SEND_MESSAGE,会话 id 用room_conversation_id(room_id)推导):房间面板(左栏加入/退出/列表)、房间模式视图({t:"room"})、回放截断警示条(replay_truncated)、乐观发言行。房间消息只进前端内存态rooms,绝不触 convs/unread/mark_read(§14)。 - 消息类型透传:
UiMessage增kind/custom_type,发送侧 kind 下拉(文本/自定义)+ 子类型输入;渲染侧 Custom 显示自定义·子类型蓝标徽记、Media/Control 徽标。 - 终态与错误事件:Kicked 顶号横幅(含重新登录按钮)、Failed、SyncProgress、LocalStoreReset 均已呈现。
- 发送闭环(重点 bug):
client_message_id是"发送中"行的身份(幂等键),去重键为pendingClientMessageId ?? messageId——按 seq 去重会互相挤掉。实测 Committed 事件可能先于invoke("send_message")返回到达前端(事件路径快于命令响应):此时注册表查不到 cmid,乐观行永不删除,出现"已确认行 + 幽灵发送中行"并存。修复:consumedCmidRef先记录先到的终态,sendMsg 拿到返回值后据此跳过乐观行(Committed 拉历史补正式行、Failed 插失败行),聊天室路径同处理。 - 重登修复:Kicked 时
loggedIn仍是 true,useEffect([loggedIn])不重跑致横幅不清。setKicked(null)/initialized.current=false移入doLogin开头,不依赖 loggedIn 翻转。 - 端到端验证(Xvfb + xdotool + OCR):单聊互发、自定义消息(桌面端显示
<Custom:demo.card>)、聊天室双向广播、房间视图未读徽标递增/打开清零、顶号横幅 + 重登恢复,全通。交付截图docs/gui-final.png。
消息持久化与完整客户端重构(2026-08-23,核心链路完成)¶
完整审计与测试计划见:
docs/designs/testplan_20260823_message_persistence_client.mddocs/designs/arch_20260823_message_persistence_client.mddocs/designs/contracts/(实现前接口冻结)
初始审计确认的发布阻断事实(本轮均已有对应修复与回归门禁):
- writer 曾在 MessageRecord 与可靠 fanout 前返回
SEND_ACK,dedup 又已进入最终态;崩溃或历史写失败后,同一 cmid 重试只回 ACK,不会补历史/分发。 - writer→mailbox 的进程内 TCP 队列曾被当成可靠 Outbox;mailbox 物化失败只剩序号,没有 payload/重放源,会永久卡连续水位。
- Redis 邮箱曾未全局按
mailbox_seq归并跨日扫描,且把完整复合序号作为 ZSET double;epoch>=32 时失去精度,损坏记录还会被静默过滤。 - Scylla 分支的旧分配器用普通 INSERT 初始化,存在跨实例覆盖 CAS 结果和序号复用;段租约未发号会在重启后形成永久水位空洞,生产一致性仍是 RF=1/ONE。
- 旧
CREATE_GROUP可把任意认证用户加入已有群;PULL_MEMBERS也缺成员准入,属于历史泄漏/消息注入漏洞。 - GUI 与 UniFFI 曾在构造 driver 后漏发
Command::Connect;GUI/CLI 默认内存 SQLite,游标 lane/epoch 未持久化,CURSOR_EXPIRED 的 REBUILD 生产链路未接通;C FFI 批量事件只导出首项。
冻结的架构决策:
SEND_ACK只由MessageCommitter的COMMITTED结果生成;提交顺序为 RESERVED → MessageRecord → Redpanda Outbox → COMMITTED,任一中间态由请求重试或恢复器续做。- 新增
qim-commit-log深模块与qim-fanout服务;使用rust-rdkafkaproducer(acks=all;writer 的 Outbox 生产者幂等,fanout 的 dispatch 生产者自 ADR-0017 起默认关闭幂等,QIM_FANOUT_PRODUCER_IDEMPOTENCE=true可改回)与显式消费者位点。Fanout 必须先取得整批 dispatch 的 durable delivery,再同步提交 source offset;允许崩溃后重复一批,由确定性dispatch_id + payload_digest收敛。ADR-0012已取代旧的 consume-transform-produce 事务选择;librdkafka/CMake 构建链与真实 broker 测试成本仍存在。 - MailboxNode 以 dispatch 日志 partition/offset 构造稳定
mailbox_seq,启动从min(W)+1重放;DispatchProgress、W与W_floor是接管真相,消费者位点只作性能提示。 - Redis 与 ScyllaDB 只实现共同
MessageStore/MailboxStore契约。Redis AOF 缺口必须由分发日志重放兜底;Scylla 生产门禁必须 RF=3 + LOCAL_QUORUM,并用 LWT 条件初始化。 - 客户端宿主默认使用平台稳定数据目录;只有测试显式选择内存库。GUI 不再 mount 自动连接默认账号,建群必须等待服务端终态。
- 客户端
CreateGroup只创建不存在的群,服务端绑定创建者并强制加入 actor;受信管理面成员变更使用独立语义,所有群读写在 writer 侧重新鉴权。 - 群消息在
conversation_seq分配后、Outbox 前固化不可变membership_version;Fanout 只能按精确版本读取快照,禁止在重放时改用当前成员。每目标 MailboxShard 恰好一条GroupDispatch,分块只属于物化进度;dispatch_id = blake3("qim.group-dispatch.v1\0" || message_id_be16 || shard_be4)[0..16]。 CommitIntent在 RESERVED 时必须持久包含完整 canonicalMessageRecord;恢复器不得依赖客户端再次上传 payload。request_id只用于当前连接的帧关联,不进入持久提交事实,重复请求用当前 request_id + 原提交坐标生成 ACK。- wire
group_id经strip_kind后用group_conversation_id归一;唯一性与授权均使用(tenant_id, canonical ConversationId),不得同时维护 raw/canonical 两套群键。 - HLC 不再仅靠进程内
last_ms:writer 启动时以(region_id, writer_id)从 Redis 原子领取严格越过历史高水位的 60s 毫秒窗口,运行中提前 20s 续领;只有持久窗口成功后才安装,窗口耗尽即拒绝发号。这样固定 writer_id、崩溃重启与时钟回拨不会复用旧 message_id;代价是 Redis 长故障时 writer 会在安全窗口耗尽后停止提交。 - 集成门禁只使用
compose.integration.yml的隔离项目、端口与卷:默认 Redis16379、Redpanda Kafka19092、Scylla CQL29042,项目名必须以qim-it-开头;清理仅允许docker compose down --volumes命中该项目,禁止FLUSHALL、全局 prune 或按进程名杀宿主服务。性能包装器必须读取实际到达率并先通过到达率门禁,不能只检查延迟。 - 隔离 wrapper 未显式设置端口/offset 时,以项目名/PID 的确定性起点扫描
0..500,一次选择 Redis core/mailbox、Redpanda Kafka/admin、Scylla、gateway/writer/mailbox/session、gateway push/dev-token 与五个 metrics 的 16 个互异 loopback 端口;显式端口在up前逐个拒绝占用。该检查存在无锁 TOCTOU 窗口,Docker 绑定冲突必须安全失败,禁止声称绝对并发无冲突。up.sh的独立wait命令必须携带完整端口、split、topic/retention 与 shard 配置。QIM_INTEGRATION_MAILBOX_REDIS_SPLIT=0让业务 mailbox 复用 core,=1使用第二实例;QIM_TEST_MAILBOX_REDIS_ADDR始终指向第二实例,shared 清理工件标为unused而不当业务指标。QIM_LOADGEN_TOKEN_ADDR是 loadgen 可选host:port覆盖,未设仍兼容<gateway-host>:8000,wrapper 必须把它精确导出为QIM_TOKEN_LISTEN。QIM_MAILBOX_SHARD_COUNT(默认 64)与QIM_SESSION_DISPATCH_PARTITION_COUNT必须相等、为1..=65536的二次幂,wait 按该数创建并回读 dispatch topic。 PULL_HISTORY必须透传并执行OLDER/NEWER、limit、max_bytes;默认/硬字节页上限为 512 KiB,第一条即使超过请求预算也返回以保证 anchor 可前进。历史记录解码损坏时整页失败,禁止filter_map静默缺行;wire 保真message_type/custom_type/created_at/body_included。内部持久body_included使用 optional 尾部字段,旧记录缺失按true解释,避免升级后把既有正文误判为治理删除。群历史按 gateway 注入的actor_user_id重新鉴权,不信任客户端目标字段。SEND_MESSAGE.client_message_id与conversation_id在 gateway 必须严格为非零 16 字节;非法值在进入 writer/dedup 前拒绝,避免零值或畸形 ID 污染幂等域。custom_type超过 64 字节返回PAYLOAD_TOO_LARGE,禁止静默清空后继续提交。- writer 已切换为
DurableMessageCommitter:发送入口先构造规范AuthenticatedSend,再由load_commit_for_request同时核对(tenant,sender,cmid)与 device/conversation/content/mention/reply 等稳定身份;合法 ACK 丢失重试在新成员关系/审核策略前续做首次 intent,不同消息复用同 cmid 必须拒绝。新请求依次推进 RESERVED → RECORDED → OUTBOXED → COMMITTED,只有CommittedMessage能生成SEND_ACK。Outbox 发布成功而状态标记失败允许产生同 payload 的重复日志记录,邮箱必须以持久dispatch_id + payload_digest去重。 - writer readiness 必须同时真实访问 MessageStore 与
OutboxPublisher::readiness,后者校验 broker、topic 和固定分区;仅成功创建 lazy producer 或对max_items=0做本地空扫描都不能开放监听。 MessageStore::history_page自身必须在任何 key 查询或空结果前执行HistoryAuthorizer:它返回可见区间HistoryVisibility{after, before}(Direct 全区间;Group 取成员区间,从未入群拒绝),各后端按区间收紧锚点并裁剪页(authz.rs两个共用函数),分层后端只授权一次。调用方预鉴权只作纵深防御(writer 预检“曾有区间”而非“当前在群”,否则退群者读不到退群前历史),授权依赖不可用时整页失败,禁止返回空页伪装成功。- Redis MessageStore 生产实例必须使用 AOF、
appendfsync=everysec|always与maxmemory-policy=noeviction;appendfsync=no、配置读取无权限或未知值均失败闭合。提交 hash、conversation record 或历史索引被逐键淘汰会破坏原子提交事实。put_record对同值的局部 record/index 缺失可幂等修复,异值或不可重建损坏必须整页/提交失败,不能用空历史伪装成功。 - Kafka
enable.idempotence只覆盖同一 producer 的协议重试,不提供应用级 publish 去重。Outbox 使用稳定message_idkey 且允许“发布成功、状态标记失败”后产生重复记录;Fanout 的确定性dispatch_id与 MailboxStore 的dispatch_id + payload_digest持久收敛才是逻辑唯一性边界,文档和指标不得宣称跨崩溃 exactly-once。 - Fanout 不使用 Kafka 事务(
ADR-0012)。旧的“每条 Outbox 一次事务”短压中仅约 100 msg/s,已被淘汰。当前QIM_FANOUT_BATCH_SIZE默认 512(DEFAULT_FANOUT_BATCH_SIZE)、合法 1..=1024;同批 source 逐条按冻结受众确定性规划,全部 dispatch 先 enqueue、再等待所有 durable delivery,最后同步提交批末实际 source offset。任一规划、投递或位点提交失败都不推进调用方位置,整批重放由稳定dispatch_id收敛。 QIM_MAILBOX_SHARD_COUNT是 writer、fanout、mailbox 三者共享的启动契约:默认 64,必须为1..=VIRTUAL_BUCKET_COUNT的二次幂;writer 冻结群 target shards、fanout 路由和 mailbox 静态消费/收件人校验必须使用同一已验证值,禁止任一服务硬编码默认值后继续启动。- writer 单条 gateway 连接最多并发处理
QIM_WRITER_REQUEST_INFLIGHT_LIMIT个请求(默认 1024、上限 8192;2026-09-25 前为固定 256),达到上限停止读 socket 并由 TCP 反压。这道闸门是一条隐形队列:触顶后请求在 gateway 后端 mpsc(4096)与内核缓冲里排队,writer 的qim_send_ack_latency_us从取得许可才计时,看不到这段。180 s 门禁 10k msg/s 下客户端 ACK P99 404~711 ms 而 writer 直方图 P99 ≤ 50 ms,差值全在这里;改 1024 后 P99 24.9 ms。现有qim_writer_request_permit_wait_us、qim_writer_requests_inflight、gateway 侧qim_gateway_send_round_trip_us(SEND 收帧到 ACK 回写)、qim_gateway_backend_queue_depth与恒零的qim_gateway_backend_dropped_total(此前后端队列满时try_send静默丢弃)把它显形。SEND_ACK与请求级 Error 通过有界响应队列可靠await send,队满不得静默丢弃;写侧断开会通知读侧关闭并使等待响应的任务失败。只有明确尽力而为且有丢弃指标的 after-send webhook 可继续try_send。 MessageType的 raw 数值必须逐字节遵守 proto:UNKNOWN=0、TEXT=1、MEDIA=2、CUSTOM=3、CONTROL=4;未来未知u32禁止降级或截成u8。历史、Outbox、GroupDispatch、MailboxEntry、SDK 全链路都保留 raw 值。GroupDispatch必须携带 canonicalsender与client_message_id;MailboxMaterializer 只对recipient == sender.user_id写入 cmid 对账锚点。v1 dispatch 缺这两个字段,无法安全物化,升级时显式拒绝而非猜值。- 日志重复记录的
observed_log_offset不属于 dispatch 身份:同dispatch_id + payload_digest在不同 offset 出现时,条目复用首次canonical_log_offset/mailbox_seq,但 64 lane 的连续水位仍推进到本次 observed offset;异 digest 必须停止服务。每 lane 的 expected chunk manifest 在begin_dispatch时持久化,advance 不能信任调用方临时传入的空列表。 - MailboxNode 已取消 writer
DispatchTCP 热路径,只服务PullMailbox;启动对每个配置 shard 的 64 lane 执行 W/W_floor 自检,以min(W)+1静态 seekread_committeddispatch 分区。物化按(lane, chunk)全量 append 后再标完成,空 lane 同样推进 observed offset;durable 完成后 Push/Projection 才允许 best-effort。QIM_MAILBOX_SHARD_COUNT在 mailbox 与 writer/fanout 同规则校验并贯穿静态分区、路由与 manifest,禁止生产路径暗含 64。 - live dispatch 物化按 shard 建立固定容量 128 的 FIFO worker:同 Kafka partition 严格串行、不同 shard 并行;队满由
await send把反压传回中心 consumer。任一 worker 失败立即标记 MailboxDirty、停止中心消费并取消其他 worker,未完成或已预读记录依靠未推进水位重放。启动 catch-up 仍在 ready 前完整完成,不能先监听再后台追赶。 RdkafkaDispatchLogConsumer禁用 auto commit/offset store、校验 partition=target_shard,并拒绝损坏、越界与缺失分区。64 分区 readiness 对每个 low/high 仍逐一校验,但并发阻塞查询,避免逐分区 timeout 线性放大;隔离 Redpanda 门禁计时要求 readiness <10 秒。Outbox producer readiness 以 metadata 验证 topic/fixed partition,不以 lazy producer 伪绿。- librdkafka 静态 assign 后、空 topic 尚未 poll 时,
position()可能短暂返回本地Offset::Invalid;这是“尚未初始化”,不是 broker 位点越界。DispatchPartitionPosition::Pending必须让 qsession 保持 checkpoint 不动并重试,其他负 offset/OffsetOutOfRange 仍失败闭合。fanout 启动期间的CoordinatorLoadInProgress、CoordinatorNotAvailable、NotCoordinator只在配置的 operation timeout 内有界重试;授权、真实 retention/position 错误不得吞掉。 - qsession 独立静态消费 dispatch 并把 Redis checkpoint 绑定
cluster_id + topic_id + partition_count;写完该 partition record 的全部 recipients 后才 CAS 推进,启动追到初始高水位后才监听。缺失/全零 topic UUID、同名重建或位点越界全部 fail closed。当前最低 broker 基线为支持 KIP-516 非零 topic UUID 的 Redpanda v26.2.2;v24.3.6 不可用于 durable projection。 MARK_READ成功后必须失效该用户的 qsession 内存快照;否则后续PULL_SESSION_LIST会在最长 5 分钟内继续返回旧 unread/read_seq。单实例已由成功后 invalidate 修复;多 qsession 实例仍要求粘性路由或跨实例版本/失效广播,未满足时不得宣称跨实例读后立即可见。- Outbox 消费只能在首次定位或 source 不连续时 seek;每次成功 poll 后必须更新本地
positioned_after。曾因空 poll 重复 seek 导致第二条饥饿,又因每条 backlog 重复 seek 将 fanout 限制到约 2 条/s;回归用真实 Redpanda 在 5 秒预算内连续消费、确认 dispatch durable 后提交 24 条 backlog 的 source offset(整组实测 1.31 秒),完整功能 smoke 15/15 通过。 - Redis mailbox 水位使用
packed-v1:wm_layout + wm_epoch + 1216B wm_blob固定保存 64 lane 的W/W_floor/W_recomputed;layout/epoch/长度/presence/三元组或旧新字段混入均失败闭合。无 epoch 证据的 legacy 192-field 水位禁止运行期在线迁移;升级必须 fence 旧 owner 后离线按显式 epoch 转换。epoch bump 时旧 packed W 读作 stale 空状态,同 ID 的旧 epoch DispatchProgress 原子轮转为新 receipt,旧 epoch GC 也必须先通过当前 W 状态校验。 - MailboxMaterializer 的用户写入使用
buffer_unordered(32)有界并发;接管时一次lane_watermarks(shard)同时推导 seek 起点并初始化 startup/live 共用的 W cache,生产 record 不再重复读 64 lane。canonical manifest 的最高(lane,chunk)是唯一 fixed final;Redisfinish_dispatch用一条 Lua 原子提交 final completion 与 64 lane packed W,典型 Fresh 单 chunk 从 5 个脚本降为begin + append + finish3 个。默认/Scylla 路径先从持久 manifest 验证 canonical final,再允许逐 lane 推进;失败可留下安全前缀,但 qmailbox 必须整条失败、置 dirty、缓存不更新,重启以持久 W 和AlreadyAdvanced收敛。 - live dispatch 仅用全局 permit 限制 durable materialize;完成 W 后的 Push/Projection 都是易失优化。Projection 只允许
try_send_keyed,满则计projection_dropped_total,不得等待并阻塞同 shard FIFO;qsession 的独立 durable dispatch consumer 承担最终恢复。分段指标固定为每 shard broker→enqueue、FIFO queue wait/depth,以及全局 permit wait/materialize;depth 用 RAII 保证失败/取消/shutdown 后归零。 - standalone Redis 增加机会型
try_materialize_fresh_single_recipient:仅 Fresh、唯一收件人/entry/canonical final chunk、可完整证明的空或同日 V4 热态,才在一次 Lua 内原子写 DP、邮箱事件组、V4 proof/identity、完成索引与 64 lane packed W。Ok(None)保证 7 个关联 key 的内容/TTL 零写并走通用链;Err必须停止 shard,网络结果未知时禁止 fallback。Existing、legacy、跨日/过期、复杂 manifest 均不冒充命中。该脚本是 standalone 专用跨 tag 事务,不代表 Redis Cluster 支持。 - 2026-08-24 同口径 split Redis、100 客户端、1000 msg/s、10 秒 A/B 中,Fresh fused 把 Mailbox
EVALSHA从 32939 降到 11839(约 3.14→1.13 次/dispatch)、Mailbox CPU 从 10.32s 降到 8.82s、materialize 从 15.47ms 降到 11.83ms、queue 从 545.04ms 降到 43.84ms,E2E P99 从 3052.5ms 降到 732.0ms,ACK P99 保持 9.5ms;工件X8ItRX/BqdY36。128 shard 单次仅到 711.1ms 且 P50/P95、queue、materialize、permit、Redis CPU/Lua 时间均恶化,不采纳。inflight=20 单次到 692.6ms,但 materialize +21.15%、Redis/Lua 成本上升且热点更集中,未采纳;现行DISPATCH_INFLIGHT_LIMIT_DEFAULT为 64、上限 256(2026-09-25 按代码核对)。三组均 10000/10000、frame/SDK 100%、failure=0,但仍未达 300ms,短压不能替代 180 秒门禁。 - 当前 1000 msg/s×10s 冷库短压并非“完全无 rehash”:约 79ms 高频采样捕获 4 个短暂 DB dict rehash 点,最大 64KiB。决定性预扩 A/B 先写 40000 个 TTL dummy key并驱动到连续2.77s、40/40样本 rehash=0,随后负载132点仍全0,但 E2E P99 仍879.9ms(冷库744.0ms)、queue>500ms 仍6.07%(冷库4.26%)。因此 rehash 不是当前700~900ms长尾的必要条件,也无证据支持其为主因;历史15k/s、180s在2^20/2^21翻倍造成尖刺的结论仍有效,但不可套用当前链路。工件
/tmp/qim-rehash-artifacts.oEgLqI、/tmp/qim-rehash-ab-artifacts.sGvwAw。 - MessageStore 提交辅助状态保留 2 小时:Redis 后端在进入 COMMITTED 时给 commit hash 设绝对过期(
committed_at_ms+ 2 h,PEXPIREAT),不再登记msgcommitted索引,writer 的 GC(每分钟 256 条)只清升级前遗留;R/D/O 中间态永不过期,由恢复器续做。此前完全依赖该 GC,持续写入超过约 4 条/秒即无界累积(2026-09-26 修复)。仅Ephemeral历史自动按 24 小时清理,Default、TenantCustom、ComplianceHold不猜固定 TTL,必须由显式租户/合规策略驱动。当前租户策略映射、冷归档与归档回读尚未实现,是发布阻断。 - 完成的
DispatchProgress由 mailbox 每分钟每 shard 最多清 256 条;GC 安全证明必须同时满足所有 lane 的 W/W_floor 已越过该 dispatch 的最大 observed offset,且最后一次重复记录被观察后已超过 replay safety window。首次完成时间或 canonical offset 不能替代这两个条件,否则旧 DP 删除后重放会重新分配 mailbox seq。 MailboxGcConfig::default()以u64::MAX禁用 DispatchProgress GC;只有部署方显式注入经审计的日志 replay safety window 才允许删除。Redis 邮箱元数据固定retention_format_version=4:活动日索引、事件组日桶身份和expired_gap_boundary共同证明自然 TTL 缺口;V1~V3、身份索引缺失、跨日同序号或损坏 proof 均失败闭合并要求迁移/日志重放。Scylla 使用同等 V4 active-ledger/generation 语义(bucket 证明按 epoch 分行、以写时间戳单调写入),dispatch 身份由 TTL 回收(ADR-0022),禁止静默跨过已物理过期的游标。- 生产 Redis 仅支持 7.0.0+ standalone:启动必须先从
INFO server精确证明唯一、严格三段式redis_version >= 7.0.0,再验证cluster_enabled=0、AOF 已开启、appendfsync=everysec|always、maxmemory-policy=noeviction。QIM_REDIS_ALLOW_UNSAFE_DEV=1只允许字面 loopback/localhost/Unix socket 跳过 AOF/fsync/noeviction,绝不绕过版本或 standalone;Redis Cluster 需要独立解决跨槽 GroupMembership、全局{commit}热槽和 Cluster 路由/迁移后才可评估。 - 内部 RPC 的生产边界为 TLS 1.3 双向认证 + 服务端 CA/SNI 校验 + 客户端叶证书 DER SHA-256 指纹角色白名单。仅 loopback 监听可显式使用明文
Local开发模式;非 loopback 缺完整 mTLS 直接拒绝启动。方法矩阵固定为:Writer/Gateway=SendMessage|PullHistory|PullMembers|CreateGroup,Writer/Admin=GroupAdd,Mailbox/Gateway=PullMailbox,GatewayPush/Mailbox=PushBatch,Session/Mailbox=Projection,Session/Gateway=SessionListReq|MarkRead;qimctl GroupAdd即使 loopback 也强制 Admin mTLS。 - 客户端宿主存储身份采用“规范化 endpoint + 显式 tenant_id + user_id”的稳定路径,禁止 token 入路径。当前单租户 legacy tenant 为
0;C/UniFFI 新版本 API 显式传 tenant,旧 ABI 固定 0。C ABI 事件结构只能版本化扩展,旧QimEvent布局与 free 必须永久保留;跨 FFI 字节所有权用 boxed slice/明确 allocator,禁止用假定capacity == len的Vec::from_raw_parts。 - Tauri/React 跨 JS 边界的 user/group/member/room/tenant 等业务 ID 一律用规范十进制字符串(内部按
u64/u128严格解析),会话 ID 保持 32 位十六进制字符串;禁止经过 JSnumber,回归必须覆盖2^53+1、u64::MAX与u128::MAX。 - SDK 接收 mailbox/push 时,进程内去重与 pending 解除都只能在 SQLite 原子落库成功后提交;失败重连必须让同批重放重新进入持久化,不能因内存
DedupSet或提前 ResolvePending 把消息/待发送事实吞掉。每设备client_message_id由 SQLiteBEGIN IMMEDIATE原子预留持久 ordinal 号段,再以完整 device_id + ordinal 的域分隔 BLAKE3 映射为 u128;外部伪造、重复消费、号段耗尽与时钟回拨均不得复用或回绕。 PUSH_EVENTS只可低延迟落库,绝不推进设备 cursor;收到 PUSH 后由状态机立即触发合并/单飞PULL_MAILBOX,在飞期间只置 dirty,末页 SQLite 提交并推进 cursor 后再决定是否续拉,PONG 仅作同一入口的兜底。性能 SDK 泳道必须按本轮 nonce + peer sender + ordinal 集合逐 pair 核验互投,收尾等待本端 committed/pending 闭合并在 pair 层比较对端 applied;不能以首次 500ms 静默或applied == 自己 emitted伪造完成。
实施 Todo(未完成前禁止声称可发布):
- [x] 完成 contracts 接口冻结与共享编码向量。
- [x] 完成日志驱动 MailboxMaterializer、Redis MessageStore、writer 提交状态机、可靠 Outbox 与 FanoutCoordinator;目标 crate、真实 Redpanda readiness/去事务 durable-delivery/backlog 与隔离功能 smoke 15/15 已通过。
- [x] 修复 Redis 精确序号/跨桶归并/裁剪、readiness 与安全 GC;Memory/Redis 共享过期游标、重复 dispatch 与保留策略故障向量,隔离 Redis 58 项通过。
- [x] 修复群创建、成员读取和群历史 initial-cohort 过渡授权;后加/退群重加/旧元数据失败闭合。
- [x] 群成员
(joined_at, left_at)可见区间(ADR-0021):群键随提交桶同实例,成员变更为占序号的 CONTROL 消息、与序号分配同一 Lua;群发送同 Lua 校验冻结版本;历史/未读/会话头按区间裁剪;LEAVE_GROUP(0x0A04)、管理面GroupRemove、qimctl membership add|remove、SDK/C/UniFFI/桌面退群;e2e 第 16 项“退群边界”。 - [x] 完成 SDK 磁盘重开、完整游标身份、REBUILD、ACK/PONG deadline、历史分页、正文/未知类型保真与 tracked 成员操作终态。
- [x] 修复 GUI/CLI/C FFI/UniFFI 的显式连接、稳定默认路径、分页事件、C V1 ABI/释放所有权、V2 严格建群关联与 JS 大整数精度。
- [x] 完成分支审计并把 Redis/Scylla 双后端接入当前主线;Scylla 纯 Rust 契约、三节点 RF3/LOCAL_QUORUM/Tablets 禁用真实门禁均通过,无静默 Redis 回退。
- [x] 在隔离 standalone Redis + Redpanda 环境运行真实集成、全工作区测试与五服务 E2E 15/15;资源均由
qim-it-*项目和精确 PID 清理。 - [ ] 在目标硬件以显式审计速率运行完整 180 秒 + T-HEAVY,并完成日志中断/进程崩溃等混沌门禁;未提供目标速率时不得擅自运行或复用旧容量结论。
- [ ] 实现
Default/TenantCustom租户保留策略、冷归档与归档回读;当前仅Ephemeral自动 24h 到期。 - [ ] 实现 durable
CommitAuditFact、FanoutAuditReceipt与独立 coverage cursor;receipt 必须遵守“dispatch 全部 durable 后、source offset 提交前”的顺序,完整 acceptance 在 orphan 审计步骤必须继续失败闭合。 - [x]
GroupDispatch.fencing_epoch:ADR-0020(负责人选方案 B)定为一期形态不适用——writer 不持序号窗口、序号由 Redis Lua 原子分配;回归测试两个_writer_并发提交同一会话_序号不重复且连续(200 条恰为 1..=200)。引入本地序号窗口/会话租约/writer 条件更新 ConversationHead 前必须先实现。 - [ ] 实现目标 lane 独立可见性推进;当前
finish_dispatch等整条 record 完成后统一推进 64 lane,空 lane 也不提前越过,M-1/用例 26.3.6 必须保持失败。 - [x]
UserConversationState会话集合兜底(ADR-0021 决策 4):ScyllaDBuser_conversation_state首次进入集合时登记(投影 Lua 写 Redis 待登记队列、后台批量落表);checkpoint 越窗/投影全失递增投影纪元、按用户惰性重建。缺口:越窗期间首次出现且之后无消息的会话无法恢复。 - [x] 在隔离三节点 Scylla 环境运行 RF3 真实门禁,覆盖 LWT、历史、邮箱 V4 retention;2026-09-28 起邮箱部分改为 ADR-0022 v2 门禁(分片前沿、写时间戳单调写、按页收尾、epoch 回退拒绝、身份 TTL),原 ghost-row/分阶段 GC 用例随 v1 表删除。
最终校验记录(2026-08-24):
cargo fmt --all -- --check、cargo check --workspace --tests、workspace clippy-D warnings、check-contract.sh、脚本bash -n与git diff --check通过。- 隔离
qim-it-*standalone Redis + Redpanda 下cargo test --workspace --no-fail-fast全通过;不提供QIM_TEST_REDIS_ADDR时 Redis 测试按设计拒绝共享 6379,不能把该安全失败误报成代码回归。 - 五服务 release 功能 smoke 15/15 通过,含 canonical 未读
3 → 0与 ACK 丢失重连对账;真实 Redpanda 的 64 分区 readiness 和 24 条 durable-delivery/backlog 两项 ignored 门禁 2/2 通过,耗时 1.31 秒。 - packed W/cache/final、Fresh fused 与独立复核修复后的定向门禁:qcommon lib 86/86、qstore lib 52/52、qmailbox 50/50、隔离 Redis mailbox 38/38、真实 Redis 版本门禁 1/1,strict clippy/fmt/contracts/diff 全通过。1000 msg/s 短压实际提交与覆盖率通过,融合后最佳 E2E P99 仍为 692.6ms,未达 300ms;性能 Todo 仍未完成。
- 所有隔离容器、卷、网络与本工作区服务 PID 已清理。已运行三节点 Scylla RF3、三主节点 Redis Cluster 负向门禁和 writer SIGKILL 恢复门禁;未运行完整 180 秒/T-HEAVY 与混沌发布门禁,短时性能运行仅作瓶颈诊断,不得冒充容量结论。
2026-09-01 文档一致性修补¶
ADR-0012是 Fanout 提交语义的现行决策:无 Kafka transaction/transactional.id;任何仍以ProducerFenced作为当前第三道防线的 PLAN、ADR 或运维文字均属漂移。- 重客户端直写仍是未实现、未实测的候选,ACK 原子性、稳定 cmid 映射、群任务恢复、会话权威重建、fencing 与同步游标尚未闭环;不得再写成“修补后只剩两条分歧”。
UserConversationState兜底和GroupDispatch.fencing_epoch均保留为目标正确性要求(2026-09-26:前者会话集合子集已由 ADR-0021 实现,后者由 ADR-0020 定为一期不适用);禁止通过删文档要求来伪装一致。- 64-lane packed 存储格式不等于 lane 隔离已实现:当前是 record 级统一推进;目标独立推进 仍是发布门禁,文档必须显式区分数据格式与行为语义。
- 邮箱时间窗到期不等待设备游标;落后游标必须由
expired_gap_boundary/effective_trim显式返回CURSOR_EXPIRED。旧“安全裁剪不越游标、用户无感”表述已作废;条目上限裁剪 与时间窗裁剪的区别是前者触发 P2,而不是只有前者会越游标。
架构复评与热路径四项优化(2026-09-25)¶
按代码逐段核对热路径后落地四项改动,均不改 wire 契约、序号语义或失败闭合姿态:
- writer:reserve 与 record 合并为一个 Lua(
reserve_and_record.lua,MessageStore::reserve_and_record默认两步实现、Redis 覆盖为单次往返)。RESERVED 时 intent 已含完整 canonicalMessageRecord,RECORDED 只是同一份记录再写历史;分开只多一次往返与一次 Lua 调用, 且落在提交侧封顶的那个实例。Fresh 直接落D,Existing 原样返回,旧R提交仍由PutRecord续做;脚本所有校验在INCR之前,非法输入不消耗序号。fault-injection下 harness 指定after-reserve时仍走两步,保留 RESERVED 中间态的 SIGKILL 向量。 - mailbox:通用物化扁平化。此前按 lane→chunk 嵌套串行,群收件人散在最多 64 个
lane、每 chunk 常只 1 人,
buffer_unordered(32)无并发可用,串行往返 ≈ 2×chunk 数。 现为 begin → 全部 (lane,chunk,user) 并发 append → 全部非 final chunk 并发标完成 → finish;任一 append 失败即零 completion、零 finish、不推 W。隔离 Redis 结构对照 (16 收件人/16 lane、debug 构建、各 100 条 × 2 轮):嵌套串行 30.0/30.3 ms/dispatch, 扁平化 14.5/12.9 ms/dispatch;下限是 Redis 单线程 Lua 时间,省的是往返不是 CPU。 - fanout:
(tenant, group, membership_version)快照进程内 FIFO 缓存(ADR-0016 决策 1, 4096 条 / 200 万成员上限)。版本不可变故命中恒正确;member_count/target_shards仍逐次核对。群 record 从每条 3 次 Redis 往返降为 0。 - qsession:脏会话增量刷新。此前每条投影 invalidate 整份快照,重度用户每条消息
换来一次 5000 会话全量重建 + 全部会话定义式未读。现只把该会话记脏(MARK_READ 同),
新一次列表首页对脏集合
HMGET read+ 一次conversation_summaries重算并换 revision; 分页续读停留在冻结快照;刷新失败把脏集合放回,绝不返回部分刷新。未读仍只在读路径 按定义式求值,不累加。指标qim_session_snapshot_rebuilt_total/refreshed_total。 - A/B(同机隔离 4+4 Redis、10,000 msg/s × 60 s、1200 连接、各一次,非容量结论), 对照 2026-08-26 基线二进制:SEND_ACK P50 11.7→9.8 ms、P99 48.6→27.3 ms;端到端 P99 141.8→118.3 ms、max 474→293 ms;投递 100%、failure 0;core Redis evalsha 3.13→2.14 次/消息、总命令 39.4→32.6 次/消息,CPU 191→185 s(Lua 内部工作量不变)。 单聊压测走 mailbox 融合路径,mailbox 与 fanout 缓存在该负载下不显形。
- 复评结论与未做项:Outbox 单分区 + 单 fanout 顺序规划是结构性单点(ADR-0016 决策 2
提议按提交桶分区,实施前须目标硬件 180 秒证明 fanout 是瓶颈);
shard % 实例数、 单QIM_GATEWAY_PUSH_ADDR是扩展上限(GroupMembership固定实例 0 已由 ADR-0021 消除);core Redis 每条 消息 33 次命令中 Lua 占其 CPU 约一半,下一杠杆仍是 Lua 内部(ADR-0015)。 - 本轮修正三处记忆漂移:fanout 批默认 512(非 128)、mailbox 全局 permit 默认 64(非 16)、
qsession 列表重建仍全量
ZREVRANGE 0 -1(非“首页只取 N”)。 - 顺带发现并修复一个既有的静默丢消息缺陷(基线二进制同样复现,非本轮引入):Redis
range_scan对零游标(raw 0,“尚未应用任何条目”)也用ZRANGEBYSCORE (0排他下界, 把log_offset = 0——每个 dispatch 分区的第一条 record——排除在外。旧 TCP 直写模型 序号从 1 起,排他下界无害;日志驱动模型后 offset 0 是合法序号,于是每个分区首条消息 对离线拉取永久不可见;在线用户靠 PUSH 拿到,压测投递 100% 不显形,只有隔离 e2e 第 3/4 项(离线暂存后登录拉取)与 writer SIGKILL 门禁的邮箱核验能抓到。修复:零游标下界-inf, 只有真正应用过(epoch, 0)的游标才排除它;回归用例零游标必须包含_offset_0_的条目_应用过_offset_0_的游标才排除。教训:压测的投递率证明的是 PUSH 路径,离线拉取路径只有 e2e 与 SIGKILL 门禁在证明;两者必须每轮都跑。 - 180 s 门禁首轮(2026-09-25,同机 14 核/28 线程、4+4 Redis、Redpanda smp=4/4G、10,000 msg/s):到达率 100% 但 SEND_ACK P99 404 ms、端到端 P99 958 ms;60 s 短压看不到。归因过程:① 每 5 s 采样 Redis 键数/fork/bgsave 与 writer 直方图——core 键数 175 s 时 94 万未到 2^20,rehash 排除;② compose Redis
--save 60 1+ 默认 AOF rewrite 门槛令 8 实例 180 s 内 fork 78 次(各 27~38 ms 停顿、子进程 6~12 s),关掉(save ""+auto-aof-rewrite-min-size 8gb)后 P99 仍 711 ms,fork 不是主因;③ writer 直方图 P99 ≤ 50 ms 而客户端 P99 700 ms,差值只能在 gateway→writer 之间——单连接在途上限 256 触顶停读。改 1024 后同条件 180 s:SEND_ACK P50 10.7 / P95 18.8 / P99 24.9 / max 57 ms,端到端 P99 111.8 / max 173 ms,到达率 100%,gateway 往返 1.8M 次中仅 18 次 >50 ms。钉核(Redis/Redpanda/服务/loadgen 各占物理核)在本机反而把 P99 推到 2.8~8 s,不采用;Redpanda smp=4 只用到 1.3 核,不是瓶颈。 - 180 s + T-HEAVY 已在本机通过一次,但存在未定位的间歇性启动瞬态(2026-09-25,
run.sh perf-180 redis --target-rate 10000,4+4 Redis、Redpanda smp=4/4G、save ""、AOF rewrite 门槛 8gb、e2e 预热后):通过轮 SEND_ACK P50 10.1 / P95 16.5 / P99 22.0 / max 64 ms,端到端 P99 111.9 / max 164 ms,到达率 100%,SDK 泳道不变量通过;T-HEAVY 5000 会话首屏 P99 1.0 ms。同配置 6 轮中 2 轮通过、4 轮在压测开始后第 10~15 s 出现一次 1~3 s 的整体停摆(该 5 s 段内 1.3 万~3.3 万条 ACK >50 ms,之后每段为 0),P99 因此落到 122~1833 ms。已排除:Redis fork/bgsave(关闭后仍现)、dict rehash(键数未到 2^20)、Kafka publish(分段 0 慢)、AOF 延迟 fsync(0)、THP(madvise);分段直方图显示 resume/reserve 只占慢请求的 1/4,其余时间不落在任何 await 段内。已加qim_runtime_lag_us(每服务 10 ms 探针)与 writer 全部分段直方图,探针轮恰好未复现。在定位并消除该瞬态前,不得把本机 180 s 通过当作稳定结论;目标硬件门禁仍待跑。 - 间歇性瞬态已定位:宿主盘
/data(SOYO MA NVME 1TB)整盘停顿,不是代码(2026-09-25 晚,重启后用 bpftrace 追踪 D 状态内核栈 + iostat 复现 2/2):负载开始的第 1~6 s,ext4 journal 提交线程jbd2/nvme0n1p1卡 1.5 s,8 个 Redis 的bio_aoffsync 线程卡 1.5~1.9 s(folio_wait_writeback/jbd2_log_wait_commit),随后 4 个 core Redis 主线程在 AOFwrite()里同时卡 2.08~2.14 s(ext4_da_write_begin → ext4_block_write_begin → __wait_on_buffer),同盘上无关的 immich postgres 也卡 1.2~2.5 s;同一秒w_await284 ms、%util52% 而只写 88 kB/s——请求已下发但盘不完成。所有 Redis 与 Redpanda 共用这一个文件系统与 journal,按实例分片隔离不了。数据改放三星盘(nvme1n1,SAMSUNG MZVL21T0HCLR,挂在/)后同配置 180 s:到达率 9,999.9/s(门禁 ≥ 9,900)、SEND_ACK P50 9.8 / P95 15.6 / P99 20.4 / max 50.7 ms、端到端 P99 110.7 / max 170 ms、SDK 泳道不变量通过、T-HEAVY 首屏 P99 2.0 ms,Redisaof-write最大 8 ms;同时段 SOYO 盘在仅 25 kB/s 写入下 jbd2 提交仍反复卡 1~3 s,确认是该盘自身问题。集成脚本新增可选QIM_INTEGRATION_DATA_ROOT(非根绝对目录):设置后本项目全部命名卷绑定到$QIM_INTEGRATION_DATA_ROOT/$QIM_INTEGRATION_PROJECT/<卷名>,down.sh用本项目 Redis 镜像的无网络一次性容器删除该目录;不迁移 Docker 全局 data-root,未设置时行为不变。本机性能门禁应使用QIM_INTEGRATION_DATA_ROOT=/app/qim-it-data(2026-09-26 起 Docker data-root 已整体迁到三星盘,见下条,此变量不再必需)。只搬数据卷还不够:三星盘第三轮 SEND_ACK 正常(P99 20.3 ms)但端到端 P99 2,607 ms——Redpanda 的 reactor-2 线程在缺页读回自身代码/库页时卡 2.4 s(filemap_fault → folio_wait_bit_common),因为镜像层与容器根文件系统仍在 SOYO 上;fanout 的 Outbox 滞后因此 1~2.5 s,约 2.4 万条(= 10k/s × 2.4 s),同时 dockerd 在/data上 fsync 卡 3.4 s。2026-09-26 00:19 把整个 Docker data-root 从/data/container/docker迁到/var/lib/docker-data(三星盘,rsync -aHAXS 57 GB、dry-run 零差异,/etc/docker/daemon.json.bak-20260925与旧目录保留作回滚;Docker 停机 23:29–00:19)。immich_postgres 与 schools-pg 用/data/...绑定挂载,仍在 SOYO 上。同一改动修了 wrapper 端口判空:本机net.ipv4.ip_local_port_range为 1024–65535,上一轮的出站连接与 TIME_WAIT 套接字会占住 7000~30000 段本地端口,原先只用 connect 探测监听者判不出,连续跑门禁时 Docker 绑定频繁address already in use;现以ss -Htan "( sport = :端口 )"任何状态的本地占用都视为不可用(TOCTOU 仍在,但不再被这类占用触发)。生产含义:每个 Redis 实例(及 Redpanda)的 AOF/日志必须落在各自独立、写延迟有保证的盘或文件系统上,共享一个 ext4 journal 时一次慢提交会同时冻结所有实例。 - 本机 180 s + T-HEAVY 门禁稳定通过(2026-09-26,Docker data-root 在三星盘、qsession 缓存与 e2e 修复后,连续 3/3):到达率 9,999.93 / 9,999.99 / 9,999.98 msg/s(门禁 ≥ 9,900);SEND_ACK P99 19.5 / 17.0 / 16.4 ms(max ≤ 47 ms);端到端 P99 107.9 / 103.4 / 104.2 ms(max ≤ 178 ms);T-HEAVY 5000 会话首屏 P99 0.8 / 0.8 / 0.7 ms;e2e 15/15、SDK 泳道不变量全通过;Redis
aof-write最大 16 ms;qsession 零告警。配置:4+4 Redis(save ""、AOF rewrite 门槛 8gb)、Redpanda smp=4/4G、1200 连接、不钉核。此为本开发机(E5-2680 v4 14C/28T、62 GB)结论,仍不是目标硬件容量结论;混沌门禁与 orphan 审计仍未完成。Redis 在此负载下每实例约 77~82% 忙,mailbox 侧 evalsha 占其 CPU 78%,线性外推约 12k msg/s 打满——下一档容量靠加实例或削减 Lua 工作量。 - 15k msg/s × 180 s 瓶颈依次显形(2026-09-26,本机 8+8 Redis、1200 连接):
① 默认配置:SEND_ACK P99 107 ms 达标,但到达率 98.9%(< 99%)、端到端 P50 8.2 s / P99 14.6 s。根因是 fanout 串行批周期:Outbox 单分区、单进程,每批“读 → 规划 → 写 dispatch 并等 Redpanda 持久确认 → 同步提交位点”,其中等确认占 32.8/34.5 ms;平均批 511 顶满上限 512,吞吐上限 ≈ 512 / 34.5 ms ≈ 14.8k/s,而 fanout 只用 0.3 核——受时延约束而非 CPU。②
QIM_FANOUT_BATCH_SIZE=1024后 Outbox 滞后全部 < 250 ms(平均批 606),积压下移到 mailbox:每 shard FIFO 串行物化,store 调用 3.2 ms 而 Redis 执行 0.2 ms,热 shard 超载(最热 5 个 shard 各积 1.6~2.2 万秒队列等待),端到端 P99 15.4 s;整机负载均值 43.65 / 28 线程、空闲 7.5%,store 调用的 3 ms 主要是进程排队等 CPU 与经127.0.0.1发布端口的用户态 docker-proxy 转发。③ 再让服务直连 Redis 容器 IP(本机测试环境专有开销,生产不存在):连续 3/3 通过:到达率 14,929.6 / 14,936.0 / 14,937.9 msg/s(门禁 ≥ 14,850);SEND_ACK P99 124.3 / 52.6 / 71.2 ms;端到端 P99 250.4 / 163.2 / 186.7 ms(max 848 / 293 / 698 ms);首屏 P99 0.7 / 0.9 / 0.8 ms;e2e 15/15、SDK 泳道全通过;整机 87~88% 忙,余量薄。fanout 批周期的真正根因在 Kafka 生产者客户端,不在 Redpanda(同日拆分计时后确认):每批 30.5 ms = 等 dispatch 确认 27.4 ms + 同步提交位点 1.8 ms + 读取规划约 1.4 ms;而 Redpanda 自报 produce 请求平均只处理 0.25 ms(99.2% < 2 ms)、只用约 1 核,Redpanda 从 4 核/4G 加到 8 核/8G 后确认等待 29.3 ms 无改善。librdkafka(2.12.1)对每个分区单独发 produce 请求,一批约 520 条 dispatch 分布在 64 个 dispatch 分区即约 64 个请求(broker 侧 produce 请求约 2,048/s ≈ 30 批/s × 64 + writer 的 Outbox 请求,互相印证)。曾推断“幂等使每连接最多 5 在途 → 约 13 轮往返”是主因,已被实测否定:按 ADR-0017 关闭 dispatch 生产者幂等后,每批等确认只从 27.4 降到 25.3 ms(−8%)。更符合数据但尚未证实的模型:64 个请求全走同一条连接、被依次处理(约 64 × 0.25 ms),叠加 librdkafka 默认linger.ms=5与本机 CPU 饱和下的各次唤醒排队。决定性验证是把生产者按分区拆成多条连接,或把 fanout 生产者linger.ms调到 0。加 Redpanda 资源已证无效,加 dispatch 分区只会让每批请求更多。qim-commit-log已新增qim_fanout_dispatch_ack_wait_us、qim_fanout_offset_commit_us、qim_fanout_dispatches_per_batch。结论:本机 15k 已在 CPU 边缘;架构上的下一瓶颈依次是 fanout 批周期(ADR-0016 决策 2 的分区/流水化是根治,调大批上限只是把上限约推到 2 倍)与 mailbox 每 shard 串行物化。 - 15k 时 CPU 按服务归因(2026-09-26,pidstat + perf 全机采样,二者一致):整机约 25 个逻辑线程忙;Redis×16 占 10.7 核(42%,自身 7.4 / musl 1.4 / 内核 2.0)、writer 4.7、mailbox 4.5、gateway 1.5(七成在内核收发)、压测客户端 1.0、Redpanda 0.9、session 0.5、fanout 0.35、docker-proxy 0.27。最大的纯浪费是 Lua 脚本 SHA-1:24 处调用点每次现场
redis::Script::new(源码),redis-rs 构造时对整段源码现算 SHA-1,mailbox 每条 dispatch 哈希 21.6 KB 融合脚本(约 324 MB/s),占 mailbox 1.5 核(三分之一)、writer 0.5 核,且在 shard 串行物化的关键路径上;仓库已有的ScriptCache从未被这些调用点使用。修复:LuaScript::script()按变体OnceLock缓存(穷尽match,新增变体忘登记即编译失败),6 处内联脚本常量同样缓存;另把热路径拼键的逐字节format!("{b:02x}")换成查表编码qim_store::hexfmt(约 0.4 核)。同配置 15k×90 s 复测:mailbox 4.53 → 3.14 核(−31%)、writer 4.67 → 4.07、整机空闲 +0.9 核;SEND_ACK P50/P99 23.9/60.0 → 13.4/31.6 ms,端到端 P50/P99 109.8/183.9 → 84.5/125.7 ms;fanout 每批等确认 24.5 → 19.0 ms(说明其中有一部分是 CPU 争用下的唤醒排队)。Redis 实例数的推算依据(同一轮):每条消息耗 Redis CPU 约 0.71 ms(core 0.34 + mailbox 0.37),15k/s 合计约 10.6 核;Redis 单线程、单实例上限 1 核且利用率超过约 0.75 后排队剧增,故至少需约 14 个实例,而 64 shard / 256 提交桶要求实例数为 2 的幂,8+8 是 15k 下的最小合法配置(10k 时 4+4,每实例约 0.8)。单实例负载:core 0.54~0.88、mailbox 0.58~0.73;最热的 0.88 是承载非分片数据(群成员、会话投影、已读、writer 租约等)的默认 core 实例,会最先饱和。要减少实例数只能降低每条消息的 Redis 工作量(Lua 与命令约 69%、musl 13%、内核收发 18%),不是换配置。剩余可优化项:writer/mailbox 的 malloc/free/memcpy 合计约 2 核;各服务内核态约 6.5 核,主要是回环 TCP 与 Docker 桥接的 netfilter(nft_do_chain等约占全机 3.4%,测试环境专有)。 - 冷启动瞬态的一个已知成分:writer 的幂等 producer 在首次 produce 前才完成 GETPID,全新 Redpanda 上会遇到
Not coordinator: retrying;服务刚就绪即打 10k msg/s 时头 10 s 内约 1.4 万条请求 ACK > 50 ms。acceptance.sh 的门禁 4(e2e)天然在门禁 5 前预热了 producer,单独跑稳态时必须保持同序;根治要让 writer readiness 覆盖 producer 就绪(不得用假记录预热 Outbox)。预热后仍出现的第 10~15 s 停摆是另一件事。 - 一期 Redis 持久化参数需要显式决策:compose 的
--save 60 1与默认auto-aof-rewrite-min-size 64mb在 10k msg/s、1.3 GB 数据集下 180 s 内每实例 fork 11~12 次(RDB 3 + AOF rewrite 8~9,各 27~38 ms 停顿、子进程 6~12 s);AOF 为主时应save ""并把 rewrite 门槛抬到数据集量级,待写入 PLAN 附录 B。 - 同轮 T-HEAVY 两次抓到 qsession 脏刷新丢标记(4987/5000、4888/5000):① 全量重建“读 Redis → 安装”窗口内到达的标记因“没有活快照”被丢;② mailbox 低延迟投影通道仍整份
invalidate,把在飞刷新之后到达的标记一并删掉,刷新结束又把旧基底装回;③ 两次并发全量重建,后装入者覆盖先装入者及其脏集合。第一版修复只堵了①。最终改为“标记序号 + 覆盖序号”:mark_dirty记全局递增序号;begin_rebuild在读 Redis 前取start、begin_refresh取脏集合时取upto,只把自己真正读到的范围记为covered;标记只在被已安装快照的covered涵盖后删除;取脏集合不删标记(失败无需回填);刷新只替换仍是原基底的快照,基底已换或已失效时绝不回装;覆盖不少于本次的快照已装入时重建让位。低延迟通道改为只打标记。7 项单测覆盖上述竞态。同日 e2e 第 6 项(群进会话列表)在约 25 次预热中失败 1 次:B 收到群 PUSH 后只读一次列表,而会话投影异步、尽力而为,可能晚于 PUSH(B 此前从未拉过列表,走的是首次全量重建,与缓存改动无关);与第 4 项同理改为 10 秒有界重拉,仍要求群最终出现。随后 e2e 又抓到增量刷新自身的 bug:读已读水位用了 redis-rshget(key, &fields),单字段时实际发HGET,字段缺失返回 nil 被转成空数组、长度不符,单会话刷新整次失败且客户端收不到应答(旧实现只在 MARK_READ 后刷新、水位必存在,故未暴露);改为显式HMGET。另注:qsession 列表处理失败时只记 WARN、不回 Error 帧,客户端只能等超时——属既有行为,待补。 -
scripts/integration/writer-kill9.sh此前写死 7000/7001/7003 等端口,只在 wrapper 恰好选到 offset 0 时通过;现改为读取 wrapper 导出的QIM_*_HOST_PORT,邮箱核验窗口 5 s → 30 s (门禁判恢复正确性而非时延),失败时先抓五服务/metrics再停进程。 -
单 Redis + 4 核小主机实测(2026-09-26,本机 cpuset 模拟,60 s 短压,非容量结论):五服务与全部容器钉在 4 个物理核(
QIM_AB_BOX_CPUS=0-3),core 与 mailbox 共用 1 个 Redis(QIM_AB_SPLIT=0 QIM_AB_SHARDS=1),Redpanda smp=1/2G,服务直连 Redis。1000/1500/2000 msg/s 通过(2000 时 ACK P99 72 ms、E2E P99 156 ms),2500 勉强通过(ACK P99 142.6 ms),3000 崩溃(提交 2895/s、时延约 7 s);拐点时 4 核只用 2.3~2.4 核,封顶的是 Redis 单线程(0.87~0.91 核)。负载越高每条消息 Redis 成本越低(1000 时 607 µs,2500 时 356 µs,批量摊薄系统调用)。模拟云上常见的 4 vCPU(2 物理核 + 超线程,0,1,14,15):evalsha 单次 70 → 93~105 µs,1500 通过、2000 崩溃,比独占物理核少约 25~35%。 - Redis 内存按键拆分(同轮 1500 msg/s,每条消息约 4.9 KB):
msgcommit(提交辅助 hash,COMMITTED 后仍保留完整 canonical MessageRecord)1865 B、dp(DispatchProgress)1119 B、邮箱条目+seq 桶约 410 B(30 天)、msgrecord+msghistory约 360 B(Default 保留无 TTL)、cmidown208 B(2 h TTL)、msgcommitted索引约 166 B、dpcomplete约 54 B。发现一处缺陷(已修复,见“历史落库与后端拆分”):writer 提交辅助状态 GC 每 60 s 全局只清 256 条(约 4.3 条/s),任何持续速率高于此都会让msgcommit无界累积;DispatchProgress GC 默认禁用,同样无界。即使 GC 跟上,2 h 窗口也要约 16 MB/(msg/s)。结论:8 GB 主机的持久上限由内存而非 CPU 决定,现行代码约 85 万条消息即写满(2000 msg/s 下约 7 分钟,noeviction 后拒写)。
历史落库与分层存储(2026-09-26,ADR-0018)¶
- 决策(项目负责人):历史消息必须落数据库,不能永久留在 Redis/AOF;但提交热路径不同步写库——近期历史本来就由热层读取,只有超出热窗口的旧消息才需要归档层。形态为
tiered:提交状态、ClientDedup、序号分配与history_hot_window(7 天)内的近期历史在 Redis(融合 Lua 不变),新服务qim-archiver以独立消费组读 Outbox(完整 canonicalMessageRecord、acks=all多副本,是比 Redis AOF 更可靠的重放源),批量普通 INSERT 写 Scylla(先记录后索引,无 LWT,同值幂等;批内同序号异内容拒绝),全部确认后才同步提交位点,再按“批前取 T0 与 Outbox 高水位 H0,位点 ≥ H0 后推进到 T0”发布归档水位msgarchive:watermark(默认 core 实例,只进不退)。writer 每 5 s 批量裁剪:只删committed_at ≤ min(now − 热窗口, 水位 − Outbox 恢复窗口 − 60 s)的记录,并抬高每会话热层下界msghistfloor;归档器停摆时只涨不删。读(TieredMessageStore):seq ≤ 下界读 Scylla、以上读 Redis,下界为 0 时与纯 Redis 完全相同;定义式未读只在“已读点低于下界且窗口 ≤ 200”时才碰 Scylla。 - 后端取值:
QIM_MESSAGE_STORE_BACKEND(writer/session)=tiered(默认,一期生产)/redis(无归档,仅开发)/scylla(提交路径同步写 Scylla,仅对照);QIM_MAILBOX_STORE_BACKEND(mailbox)=redis(默认)/scylla;旧全局QIM_STORE_BACKEND出现即拒绝启动。集成脚本gate hybrid= tiered + Redis 邮箱,wrapper 导出各 Scylla 节点的容器地址(驱动沿用联系点端口拼接system.peers,127.0.0.1:<宿主端口>会让发现到的节点全部连接被拒)。Scylla 节点数(2026-09-27,负责人决定测试环境用单节点):QIM_INTEGRATION_SCYLLA_NODES取 1 或 3;gate hybrid(含acceptance.sh完整门禁与性能 harness)默认 1 节点 RF=1,并导出QIM_SCYLLA_ALLOW_SINGLE_NODE_DEV=1——ScyllaConfig只在该开关下放行 RF=1(不放行 RF=2),生产不得设置;gate scylla证明 RF3/LOCAL_QUORUM/LWT 跨节点语义,固定 3 节点、拒绝覆盖。acceptance.sh完整门禁已改为perf-180 hybrid,门禁 2 追加tiered_message_store --ignored、门禁 3 启动qim-archiver(metrics 9107)。 - 实测(本机,Scylla 三节点各 1 核 1G,1000 msg/s × 60 s):e2e 15/15;SEND_ACK P50/P99 4.9/7.7 ms、端到端 P99 70 ms;Scylla 归档 60,048 行 = 压测 60,000 + e2e 48,归档失败 0、积压 0、水位滞后约 1 s;Scylla 每节点 0.14 核(138 µs/条),archiver 0.11 核。对照同步写 Scylla(
scylla后端)在 200 msg/s 即 ACK P50/P99 184/252 ms:每条约 6.5 次串行 LWT,LWT 三个 Paxos 阶段都等副本刷盘,本机三星 PM9A1 消费级盘 4 KiB 同步写 6.5 ms(QIM_SCYLLA_UNSAFE_BYPASS_FSYNC=1模拟服务器盘后 LWT 31.5→3.1 ms,仅归因用),且每条消息每节点约 33 次写、23 次读、3.7~4.7 ms CPU。 - 10k msg/s × 180 s(本机,hybrid,与此前纯 Redis 通过轮同条件:4+4 Redis 直连、
save ""、AOF rewrite 门槛 8gb、Redpanda 4/4G、Scylla 三节点各 1 核):到达率 9,999.96/s 通过;SEND_ACK P99 16.8 ms、端到端 P99 107.8 / max 226 ms(纯 Redis 同条件 103~108 ms,分层无可测的时延代价);T-HEAVY 首屏 P99 0.8 ms;e2e 15/15;Scylla 归档 1,800,040 行与 archiver 计数一致,失败 0、积压 0、水位滞后 479 ms;Scylla 每节点 0.78 核、archiver 0.69 核。同日acceptance.sh(已改为perf-180 hybrid)门禁 1~4 全过(642 测试、e2e 15/15)、门禁 5 到达率与 ACK 通过但端到端 P99 366 ms 超标——该脚本走 docker-proxy 访问 Redis 且沿用 compose 默认持久化(core 180 s 内 fork 12 次),与上面同条件对照的唯一差异即此;acceptance 此前从未以分片 4+4 跑过(邮箱地址校验只认单实例,已修)。 - 提交辅助状态内存泄漏已修:此前 COMMITTED hash 无 TTL、靠 writer 每分钟清 256 条(约 4.3 条/s),持续写入超过即无界累积(4c8g 单 Redis 实测每条约 4.9 KB 驻留)。现进入 COMMITTED 时
PEXPIREAT committed_at + 2h,不再登记msgcommitted;writer 各类回收每轮取尽(批 1024、每轮 2 s 预算)。 - 提交事实持久化界限(2026-09-26 修复):writer 连接的 Redis(默认 core 与全部 MessageStore 分片)启动探测强制
appendfsync always且no-appendfsync-on-rewrite no(RedisDurability::CommitFacts,assert_redis_runtime_durable),不满足拒绝启动;邮箱/fanout/session/archiver 仍接受everysec。否则宿主崩溃丢约 1 秒会让msgcseq/HLC 回退、复用已归档序号并静默覆盖归档。未采用“检测回退后按 Outbox 重新播种”:崩溃前预留、崩溃后才发布的记录扫描时可能不可见,竞态面过大。compose 的redis/redis-core-*已改always,集成预检按实例区分。always的代价取决于盘:Redis 每轮事件循环回复前统一 fsync,单连接每轮最多读 16 KB(提交脚本 1~2 KB/条),本机消费级盘单次刷盘 13~17 ms——单连接时 10k msg/s 提交塌到 1,873/s、ACK P50 17.7 s;writer 改为每实例连接池(QIM_MESSAGE_STORE_CONNECTIONS,默认 8)后 5k msg/s 全量提交但 ACK P50/P99 129/184 ms 仍超 SLO。生产必须带掉电保护的服务器 SSD(与 ScyllaDB 同一要求)。用 tmpfs 承载全部数据卷模拟“刷盘近乎免费”的服务器盘(仅归因,非持久性结论),同条件 hybrid 10k×180 s:到达率 9,999.93/s、SEND_ACK P50/P99 11.5/25.1 ms、端到端 P99 114.6 ms、T-HEAVY 0.7 ms、e2e 15/15、孤儿审计 1,800,035 提交/1,887,082 dispatch 四类计数全 0、归档 1,800,035 行;对照 everysec 真实盘 ACK P99 16.8 ms,always代价约 +8 ms。默认 core 实例 1.01 核已达单线程上限,10k 生产应按 8 core 实例配。 - 历史保留期(2026-09-26,ADR-0019,负责人决定“保留 1 个月、可配置”):
default保留 30 天(DEFAULT_HISTORY_RETENTION_DAYS,QIM_DEFAULT_HISTORY_RETENTION_DAYS1..=3650),qim_store::RetentionPolicy统一算到期(created_at+ 保留期);writer 在 COMMITTED 时登记 Redis 热层到期 GC,qim-archiver 按剩余期限写 Scylla 逐行 TTL,两进程读同一变量;ephemeral_24h固定 24 h,compliance_hold永不到期,tenant_custom失败闭合为不到期;库内缺省不到期(保留期必须由装配方注入)。一期不做对象存储冷归档与归档回读。真实门禁:Redis 到期删除/合规保留不删、Scylla TTL≈30 天/合规无 TTL。 - 热窗口裁剪与到期清理的索引缺陷(2026-09-27 修复):裁剪在第 7 天删记录却留下
msghistorygc条目,第 30 天到期清理遇到“记录不存在”即判损坏、整批中止并永久卡住(每条约 157 B 只增不减)。现裁剪同时移除该条目;到期清理对“序号不高于热层下界”的遗留条目只清索引(返回 2),下界之上缺失仍失败闭合。见 ADR-0019 修订。 - orphan 独立审计已实现(2026-09-26):
qim-commit-log::audit::CoverageAudit+qimctl audit dispatch——先等提交恢复索引排空(RedisMessageStore::pending_commit_count)与 fanout 追平 Outbox 高水位,再以 Outbox(提交事实:ACK 前必已 durable、带完整记录与冻结受众)推导应有(message_id, target_shard)(单聊按收件人路由、群聊取冻结分片),与 dispatch 日志(展开回执)逐条比对;有提交无 dispatch、有 dispatch 无提交、坐标/发送者不一致、分区错位均须为 0。7 项单测覆盖正负例;hybrid 5k×60 s 实测 300,048 提交、315,095/315,095 dispatch、四类计数全 0。acceptance.sh门禁 9 已改为执行该审计。生产常驻增量审计(持久覆盖游标)未实现。 - 15k msg/s × 180 s(2026-09-27,本机、tmpfs 模拟服务器盘、8+8 Redis 直连、fanout 批 1024,含 ADR-0021,各一次):纯 Redis 形态通过——提交 14,969/s、SEND_ACK P50/P99 19.3/53.1 ms、端到端 P50/P99 93.6/146.4 ms、首屏 0.7 ms、e2e 16/16、孤儿审计 0,整机 86% 忙。分层(生产形态)不通过——提交 14,849/s(门禁 14,850)、SEND_ACK P99 620.8 ms、端到端 P99 2.68 s;正确性无损(孤儿审计 0、归档 2,672,959 行全部、failure 0,SDK 泳道“缺 30 条”是端到端 max 7.4 s 超过 5.1 s 收尾等待的迟到)。归因:整机 28 线程 90% 忙(Scylla×3 与 archiver 多约 2.9 核),writer 内部 99.5% < 50 ms、许可等待 99.6% < 1 ms,但 gateway 往返 ≤ 50 ms 从 99% 降到 17%、后端队列平均深度 15 → 40——时间耗在进程间排队等 CPU,不在某个组件。结论只说明单台 14 核开发机容纳不下 15k 的全部组件;生产 Scylla 应独立部署,目标硬件门禁仍待跑。
- 改单节点 Scylla 后分层形态 15k 通过(2026-09-27,同上配置,Scylla 1 节点 RF=1):提交 14,927/s(门禁 ≥ 14,850)、SEND_ACK P50/P99 27.5/67.2 ms、端到端 P50/P99 111.0/170.8 ms(max 284 ms)、首屏 0.9 ms、e2e 16/16、SDK 泳道不变量通过、孤儿审计 0、归档 2,686,958 行、失败 0、积压 0;整机 88% 忙(此前三节点 90%),Scylla 单节点 0.87 核、archiver 0.45 核。对照同日纯 Redis 形态 ACK/端到端 P99 53.1/146.4 ms,分层在本机 15k 下多约 14/24 ms。
- ScyllaDB MailboxStore 重做为 v2(2026-09-28,ADR-0022,负责人“全都做;ScyllaDB 能不能按页写”):原实现 20 msg/s 只投递约 2 msg/s(每条 dispatch 约 4,070 次 LWT,
advance_watermark逐 lane 且每次对 64 个 manifest 重做ensure_source);去冗余(A)后约 130 LWT、4 核上限约 110 msg/s。单次 LWT 约 220 µs Scylla CPU,Paxos 状态写强制同步刷盘——“每次落盘”的来源就是 LWT。v2:①分片前沿取代逐 lane W,mailbox_shard_frontier(shard, epoch DESC, frontier_offset)取最高 epoch 行,64 lane 同值,lane 独立推进(M-1)拒绝;②单调列以USING TIMESTAMP写、热路径零 LWT:前沿与保留期 bucket 证明(mailbox_retention_bucket_v2,按 epoch 分行)写时间戳 = log offset,dispatch 身份mailbox_dispatch((shard, epoch, dispatch_id))写时间戳 =2^48−1−offset(最早 observation 胜)。时间戳只能用 48 位 offset:Scylla 默认拒绝超过“当前 + 3 天”的时间戳,含 epoch 高位的mailbox_seq在 epoch ≥ 7 即越界(首版这样写,契约门禁当场报错);③身份行 TTL 31 天、gc_completed_dispatches恒 0,chunk 完成只记在进程内在途表,重启后整条重放;qim-mailbox 以 Scylla 启动时校验QIM_OUTBOX_RETENTION_MS + QIM_DISPATCH_RETENTION_MS< 31 天;④按页推进:MailboxStore::finish_page_limit()(默认 1,Scylla 64)+finish_dispatch_page,worker 取到一条后把同 FIFO 已排队的 record 一并取出(不等凑页),并发写完条目后一次写前沿;QIM_MAILBOX_FINISH_PAGE_MAX只能调低。实测(单节点 4 核、tmpfs、4+4 Redis、各 60 s 一次):200 msg/s 投递 100%、端到端 P99 111 ms、Scylla 0.2 核;2,000 msg/s P99 73.7 ms、0.7 核;10,000 msg/s 投递 100%、ACK/端到端 P99 26.6/131.4 ms、Scylla 2.6 核(含归档)、平均页 4.46、e2e 16/16;同条件页上限 1 端到端 P99 904.9 ms、Scylla 3.4 核;Redis 邮箱对照 23.0/110.5 ms。每条消息 Scylla 插入 4.10(归档占 2)、更新 1.30、读 1.46、LWT 0。生产承载邮箱 keyspace 的集群必须commitlog_sync: batch(默认 periodic 10 s 不抗多节点同时掉电)。v1 的dispatch_progress/dispatch_chunk/lane_dispatch/lane_watermark/dispatch_observation/completed_dispatch表与分阶段 GC 已删除(无生产数据,不迁移)。另:本机fs.aio-max-nr65536 不足以同时运行 Redpanda 4 核与 Scylla 4 核,已改为 1048576 并持久化到/etc/sysctl.d/60-qim-aio.conf。 - Redis DispatchProgress 稀疏 manifest(2026-09-28):DP hash 的
manifest字段原为 64 lane 全量 v1 编码(≥ 324 B),任一字段值超过hash-max-listpack-value(64 B)就把整条 hash 从 listpack 转成 hashtable。现写DPM\x02(只含非空 lane,lane 1 字节 + LEB128 count/chunk_id,单 lane 单 chunk 7 字节,拒绝非最短编码),读兼容 v1;begin_dispatch.lua对已存 v1 从 ARGV 重建 v1 字节比较 identity,快路径 O(1) 校验 v2 形态。实测(hybrid、1+1 Redis、2k msg/s×60 s):每条 DP 2,173 → 472 B,邮箱 Redis 总内存 238 → 113 MB(同 12 万条,−52%)。升级后不可回滚到旧二进制(旧代码不认 v2)。 - Redis 提交辅助状态压缩与到期索引裁撤(2026-09-28,负责人“做内存优化”):① 提交 hash 进入 COMMITTED 时在同一 Lua 内读出身份与序号、
DEL后重建为只含回放 ACK 的坐标(identity摘要、conversation_seq、outbox、state、committed_at_ms、history_gc_member与 16 字节的message_id/last_activity_id/trace_id/conversation_id),删掉各含一份完整记录的immutable/intent/record与recovery_member;必须重建而非 HDEL——hash 编码只会从 listpack 单向升级为 hashtable,删字段不会退回。② 请求身份此前含完整正文,现存AuthenticatedSend::reserve_identity_digest(QIMD+ 域分隔 BLAKE3,36 B);三个预留脚本读到升级前的完整身份(QIMC)返回L:seq,由 Rust 逐字节比较。③ 接口:ReserveResult::Committed(CommittedMessage)与LoadedCommit::{InFlight, Committed},writer 状态机对已提交的直接回放 ACK;Memory/Scylla 不压缩存储、只做类型映射。④ 分层写入方(with_trim→with_trim_window)对保留期 ≥ 热窗口 + 1 天的消息不登记msghistorygc(它们必先被热层裁剪),见 ADR-0019 修订 2。实测(100 B 正文)单条提交 hash 2,640 B(hashtable)→ listpack;hybrid 1+1 Redis、2k msg/s×60 s:msgcommit1,917 → 552 B/条,core Redis 345.5 → 151.9 MB,连同 DP 稀疏化两台 Redis 合计 583.6 → 265.1 MB(每条 4.86 → 2.21 KB,−55%);10k msg/s×60 s(4+4、tmpfs)Redis 总内存 2,270.6 → 1,348.1 MiB,ACK/端到端 P99 24.4/109.0 ms(对照 23.0/110.5)无回归。剩余大头:msgrecord235 B 与msghottrim约 106 B(7 天热窗口)、cmidown208 B(2 h)、DP 472 B(7 天,DISPATCH_PROGRESS_RETENTION_MILLIS是附录 B 契约常量,缩短需改 PLAN)。fs.aio-max-nr = 1048576已持久化到/etc/sysctl.d/60-qim-aio.conf。 - 离线消息 7 天、历史 30 天(2026-09-28,ADR-0023,负责人决定):
MAILBOX_RETENTION_DAYS30 → 7(契约常量,PLAN 附录 B.3;qim-store、Scylla 账本、融合 Lua 一律引用它,融合脚本改为由 Rust 传入天数);日桶到期(D + 7 + 1) × 86400 s,至少 7 整天。离线消息定义:新客户端登录后至少能拉回 7 天的消息,更早的走PULL_HISTORY。实现为零游标(last_applied_mailbox_seq原值 0)不判过期,读回保留窗口内尚存的全部条目(显式 trim 之后);非零游标越过过期边界仍CURSOR_EXPIRED→ REBUILD(PLAN §9.6.1 已改写)。升级时旧 30 天日桶会与新到期时刻冲突失败闭合,需清空邮箱数据。 - DispatchProgress 回收上限缺陷已修(2026-09-28):原每分钟每 shard 至多 256 个(64 shard 约 273 个/s),群扇出下平均 2~4 千 dispatch/s 只增不减;现
qim-mailbox::dispatch_gc每 10 s 一轮、每 shard 连续取批(1024)直到不满或 5 s 预算用完。保留期 7 天(DISPATCH_PROGRESS_RETENTION_MILLIS)未改。 - 群扇出压测(2026-09-28,loadgen
--group-size,harnessQIM_AB_GROUP_SIZE):帧层连接按群大小分组、首成员建群、全员发群消息;发送时刻打在内联正文里,每个收件人记一个端到端样本,门禁按“应投人次”判覆盖。一期峰值口径 3,800 msg/s × 22.6 人 ≈ 8.2 万 entry/s(本机、tmpfs、各 60 s 一次):提交侧达标(ACK P99 21~24 ms,fanout 每批 1.3k dispatch、等确认 13~15 ms、Outbox 滞后约 18~20 ms,跟得上);投递不达标——Redis 邮箱 8+8:2.04 万 entry/s(应投的 25%)、端到端 P50 25.8 s;Scylla v2(8 核):3.86 万 entry/s(47%)、P50 16.4 s;页内并发 8 → 32 反而降到 3.32 万。共同根因是 dispatch 放大:64 shard 下 22 人群每条消息约 18.7 个 dispatch、每个仅 1.15 条条目,按 dispatch 计的固定成本主导——Redis 每 dispatch 约 2.3 ms 串行往返(每 shard 上限约 430 个/s,合计约 2.7 万/s)且约 310 µs Redis CPU(峰值需约 22 核);Scylla 每页 63.6 ms(平均页 47.8),mailbox 进程 6.3 核 + Scylla 6.1 核。500 msg/s(1 万 entry/s)时群路径端到端 P99 已 228 ms(单聊 1 万 msg/s 约 110 ms)。 - 仍未完成(发布阻断):② 升级前已在 Redis、不在 Outbox 保留期内的历史需一次性回填 Scylla 后才能裁剪;③
scylla后端(非生产)的message_commit/commit_recovery表级 TTL 对所有状态生效,卡住超 2 h 的提交会消失,另有 UPDATE 半行 TTL 问题;④(已解决)本机fs.aio-max-nr已持久化为 1048576。 - Scylla MessageStore 的
invariant()此前丢弃错误原因;现先记 ERROR 日志。ScyllaConfig::from_env()为共享解析入口。
群成员可见区间与会话集合权威(2026-09-26,ADR-0021)¶
- 群键与会话提交事实同桶:
grp/grpmeta/grpsnap/grpmember:{bNNN}:…,{bNNN}由qim_common::bucket::commit_bucket(tenant, group_cid)决定;writer/fanout/session/qimctl 一律按qim_common::bucket::commit_instance_addrs(QIM_REDIS_ADDR+QIM_MESSAGE_STORE_ADDRS,同序)装配GroupMembership::new_sharded,Redis/分层后端直接复用RedisMessageStore::instance_connections()。上线前必须清空旧格式群键(无生产数据,不迁移)。 - 分配之后冻结的原子化:
reserve_and_record.lua/reserve_commit.lua对GroupSnapshot受众多带群 meta 键与冻结版本,在 INCR 前比较,不符返回V→MessageStoreError::MembershipChanged(不消耗序号);writer 发送路径重新冻结、有界重试 3 次,仍冲突回RATE_LIMITED。既有提交(ACK 丢失重试)在版本校验之前返回,不受成员变化影响。 - 成员变更 = 占序号的 CONTROL 消息(
custom_type = qim.membership.v1,正文op|actor|n|users):membership_change.lua一次完成版本校验 → INCR 得 J → 改成员集合/区间/版本 V+1 快照 → 记录提交(RECORDED),之后与普通消息同一状态机。规划在 writer(membership.rs)按当前版本过滤出真实对象,空则 NoOp;cmid 由(tenant, group, op, actor, V, users)确定性派生。受众为显式Direct:变更后成员 ≤ 500 时广播(含变更对象),否则只给变更对象。管理面操作的记录发送者是群 owner、正文actor=0。对照用scyllaMessageStore 后端返回Unsupported。 - 读路径:
HistoryAuthorizer返回区间;qsession::visibility对群会话base = max(read, joined_at),已离开者经MessageStore::visible_conversation_summary(走history_page,共用授权与裁剪)求有上界的头与未读;缺区间的旧群会话隐藏并计qim_session_group_interval_missing_total(恒零)。PULL_MEMBERS仅当前成员、回带真实joined_at_conversation_seq(Rpc tag 76member_joined_at)。 - UserConversationState 一期只存会话集合(
kind, first_seen_at),不复制成员区间(权威在grpmember,且大群成员事件正文不内联进 dispatch,qsession 读不到对象)。写入:投影 LuaZSCORE为空时 RPUSHsession_projection:ucs_pending,后台任务批量写表后按原样逐项出队。checkpoint 缺失且低水位非零、或越过低水位 →INCR session_projection:epoch、从低水位继续;越过高水位或身份不符仍失败闭合。列表全量重建前ensure_user_rebuilt比较session_projection:user_epoch。 两条投影路径必须共用登记 Lua(conversation_state::project_entries,2026-09-27 修复):mailbox 低延迟通道此前直接ZADD GT,通常先到,durable 侧看到成员已存在就不登记,180 s 实测 250 万条投影只登记 15 个会话;单测直接调project_recipients覆盖不到这个到达顺序,只有真实负载的登记计数暴露了它。也不能让低延迟通道只更新已有成员:持续 10k 负载下 durable 投影远远落后,集合新鲜度靠低延迟通道(实测 T-HEAVY 30 秒只见 730/5000)。 - 顺带修复的既有缺陷:Redis 邮箱条目解码器读完非空
custom_type后没有推进偏移,任何带custom_type的条目都被判“message_type v2 尾部字段截断或版本非法”、拉取即MAILBOX_DIRTY。自定义消息此前未走过 Redis 邮箱拉取路径所以一直没暴露;退群 e2e(控制消息恒带custom_type)抓到。教训:编解码往返测试必须覆盖每个可选字段的非空形态。 - 性能代价(2026-09-27 实测,hybrid、tmpfs、4+4、10k × 180 s,各一次):SEND_ACK P99 25.1 → 30.0 ms、端到端 P99 114.6 → 122.2 ms,门禁全过(T-HEAVY 0.9 ms、e2e 16/16、孤儿审计 0)。来源是登记 Lua(约 6 µs/次,原
ZADD0.9 µs)落在已 1.0 核封顶的默认 core 实例;可削减方向是 durable 登记与 checkpoint CAS 合并一次调用,或把 qsession 键移出默认实例。 - 验收映射见 ADR-0021“实现与验收映射”;GUI 未加退群入口。
邮箱热路径性能与容量(2026-08-26,ADR-0014)¶
- mailbox / core Redis 实例数是容量参数,不是调优旋钮:单线程 Redis 打满即
ρ≈1,服务时间 237µs 的 evalsha 客户端观测到 4,388µs(18 倍排队)。
QIM_MAILBOX_REDIS_SHARDS/QIM_MESSAGE_STORE_SHARDS值域均为 1..8 (compose 内置 8 个实例;64 shard、256 提交桶必须被实例数整除)。 实测 10,000 msg/s:4+4 端到端 P99 5,208ms → 8+4 降至 113.2ms、8+8 在 12,000 msg/s 为 P99 343.6ms,提交封顶 4 核约 9,650/s、8 核约 12,120/s。 均为 60 秒短压,不是容量结论;发布仍需目标硬件 180 秒 + T-HEAVY + 混沌。 - 中心 dispatch consumer 队满只暂停该 Kafka 分区(
pause_shard/resume_shard): 此前队满在中心循环await,一个热点 shard 停掉全部 64 分区,实测消费者 99% 墙钟卡在 enqueue。修复是隔离而非扩容:过载时 P50 2,940ms→110ms 但 P99 5,208ms→15,275ms、聚合吞吐 −1.9%;不过载时暂停 0 次完全惰性。 饱和度看qim_mailbox_dispatch_partition_paused_total。 qim_mailbox_dispatch_broker_to_enqueue_us名不副实:计时起点是消费者 已读到 record 之后,不含 broker 滞后;分区暂停落地后其 sum/墙钟会超过 100% (实测 186%),不可再用作消费者饱和度。- 单线程热路径的成本归因必须直接 perf 采样,不能由聚合指标做减法:
同一问题上四次推断被实测推翻(详见 ADR-0014)。
cmdstat不归属 Lua↔C 桥接 与写效果传播;perf 采样不区分是否在关键路径(RDB bgsave 在 fork 子进程、 另一个核,占 5.58% 采样但关闭后零收益);微基准的键分布必须与生产一致 (每次写新键量到的是建键成本,生产是更新既有键)。 - packed watermark v2(ADR-0015):
wm_blob长度自身编码「是否全同」—— 19 字节 = 64 lane 均等于该条记录,1216 字节 = 逐 lane,合法长度只有这两个。uniform_blob由「两次 1197 字节string.sub比较」退化为 O(1) 长度判定, 全同时写回 19 字节而非string.rep出 1216 字节。实测 10,000 msg/s、4 实例: evalsha 225.35→186.51µs(−17.2%)、Redis CPU 99.7%→85.8%、 端到端 P99 10,657→131.8ms、投递 99.3%→100%。 五个 Lua 脚本读兼容 v1,三个写入点一律 v2;旧代码遇 v2 在 layout 与长度 两处独立失败闭合,故同 shard 内不支持滚动升级,必须先 fence 旧 owner。 实施时修掉三处压缩引入的越界:热路径与复数版 advance 的慢分支 (19 字节 blob 上逐 lane 索引越界)、gc_completed_dispatch.lua逐 lane 读 会取到 nil 而让 GC 永远拒绝(失败闭合但错误)。 - 容量阶梯(packed-v2 后,同机 60 秒短压,非容量结论): 4+4 实例可撑 10,000 msg/s(P99 131.8ms、CPU 85.8%);4+8 在 12,000 时 mailbox CPU 99.6% 饱和;8+8 在 15,000 时 committed 12,989/s 由提交侧封顶, mailbox CPU 仅 67.3%——约束已从邮箱侧转移到 MessageStore 侧。
- perf 符号化方式:
redis:7.4.1-alpine与redis:7.4.1build-id 一致, 把未 strip 的 Debian 版二进制加入perf buildid-cache即可。 实测 Redis CPU 构成:Lua 解释器 34.5%(其中luaS_newlstr6.59%)、 libc+内核+未分类 40.6%、Redis 数据结构 9.6%、jemalloc 8.8%。 下一个杠杆是luaS_newlstr,由uniform_blob对 1216 字节 blob 的两次string.sub驱动;压缩 packed W 布局需独立 ADR 与离线迁移路径。
客户端绑定兼容修复(2026-08-23)¶
- C FFI 的
QimEvent、qim_next_event与qim_event_free是永久 V1 ABI;分页、成员关联、历史锚点和 rawmessage_type只经QimEventV2/qim_next_event_v2/qim_event_v2_free发布。V1/V2 读取同一事件流,单个客户端不得交替消费后期待事件重放。 - FFI 字节缓冲一律由
Box<[u8]>转移与按切片长度回收,禁止以Vec::from_raw_parts(ptr, len, len)假定 capacity。回归测试锁定 V1 布局、V1/V2 幂等释放和大容量 Vec 的所有权转移。 - C 构造 V2 显式接收 tenant/user/device;旧构造保持符号与用户=设备的 legacy 语义、tenant=0。构造只打开本地库并启动 driver,网络连接必须由
qim_connect显式发起;内存库 C 入口仅在cfg(test)编译。 - UniFFI 保持 V1 Record/枚举字段冻结,新增字段使用
QimConfigV2、QimConversationV2、QimMessageV2、QimEventV2和 V2 查询方法;V1 表面由可构造/穷尽匹配测试守门,生产绑定不导出内存库选择。 - 桌面/Tauri 默认库路径必须含规范化 endpoint(地址、CA、SNI、安全模式)+ 显式 tenant + user,tenant 路径段为 32 位十六进制;当前单租户 legacy tenant 固定
0。Tauri tenant 跨 JS 边界必须传字符串并解析为 u128,禁止 JS number 精度损失。 MembersUpdated必须携带并消费operation + request_id;建群 UI 只能接受CreateGroup操作的终态,不能把同 group 的PullMembers页误报为建群成功。- endpoint 路径段使用
blake3("qim.endpoint-scope.v1\\0" || canonical_endpoint)[0..16]的 32 位小写十六进制,而非完整 endpoint 十六进制:BLAKE3 已是工作区依赖、跨平台稳定且避免超过常见 255 字节目录段;代价是路径不可读,排障时仅记录该脱敏 scope。C/UniFFI 暴露同一 V1 scope 计算 API,便于其宿主构造一致路径。 send_tracked(TrackedCommand)在 driver 成功写帧后返回实际 wirerequest_id;desktop/Tauri 和 C/UniFFI V2 的建群都保存该值,成功MembersUpdated与失败MemberOperationFailed均只接受request_id + operation + group_id三元组。普通Failed不得结束成员操作;V1 C/UniFFI 表面冻结,成员专用失败仅降级为既有通用失败,严格关联必须使用 V2。- [x] 客户端宿主/绑定 P0:冻结 C V1 事件 ABI、用 V2 承载新增字段,修复 boxed-slice 释放、显式 tenant/user 构造、禁止生产内存库、endpoint/tenant/user SQLite 隔离、严格建群终态和 UniFFI V1 快照门禁。