- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
导读:本文以 WuKongIM 仓库中
docs/superpowers/plans/2026-05-13-send-cmd-conversation-intent-p2d-d.md这份实现计划为主体,系统讲解 P2d-d 阶段如何用"CMD 会话更新意图(Conversation Intent)+ UID 属主本地待处理缓冲(Pending Buffer)"替代 P2d-c 的 post-commit 订阅者扫描投影,从而在不引入持久化订阅者快照表的前提下恢复旧版兼容的 CMD 离线同步语义。读完本文,你将掌握意图构建规则、待处理缓冲的合并/落盘/恢复机制、/message/sync待处理覆盖层与/message/syncack清理契约、UID-owner 路由与节点 RPC 适配,以及当前仓库中该模型后续演进为UserCMDChannelMembership成员关系模型的实现现状。
背景与目标:为什么需要 P2d-d
WuKongIM 的 CMD(命令)频道承载系统命令类消息。历史实现中,ConversationManager并不会持久化每条消息的订阅者快照,而是借助 distribute 路径已经解析好的 delivery tag,把 tag 的本地 UID 分区转换成"待处理会话更新"(pending conversation update),周期性冲刷为ConversationTypeCMD行;优雅停机会把未冲刷的待处理更新保存到本地 JSON 文件并在启动时恢复。这正是 P2d-d 要恢复的旧版兼容模型,其完整链路可以概括为:
resolved CMD recipients -> CMD conversation update intent -> UID-owner pending buffer -> periodic flush to CMDConversationState -> /message/sync overlays pending + persisted state在 P2d-c 阶段(参见 P2d-c 实现计划),仓库已经引入了专用的CMDConversationState表、UID 属主的/message/sync与/message/syncack接口,以及一个cmdsync.Projector。但当时的投影模型仍然把 CMD 更新建模为"提交后的订阅者扫描/投影"(MessageCommitted -> cmdsync.Projector -> page SubscriberResolver after commit -> write CMDConversationState directly),这带来两个遗留问题:
- request-scoped durable 发送仍然依赖请求局部的
MessageScopedUIDs,进程重启后的 committed replay 无法从频道日志重建出当时的请求接收者范围; - 未冲刷的 CMD 活跃状态没有以旧式"待处理会话更新"形态暴露给
/message/sync。
P2d-d 的目标(对应 P2d-d 设计文档)是:
- 使用已经解析好的接收者来源(而非提交后二次扫描)构建 CMD 会话更新;
- 新增 UID 属主本地的待处理 CMD 会话更新器,支持优雅停机时文件恢复;
- 把待处理 CMD 会话合并进
/message/sync候选选择; - 在
/message/syncack时清理待处理 UID/频道条目; - 在写入待处理缓冲之前,先把意图路由到 UID 属主;
- 用"请求接收者 + delivery UID 页"的意图写入替换 P2d-c 的
cmdsync.Projector订阅者扫描写入; - 保持普通
/conversation/sync与 CMD 待处理/状态完全隔离。
明确不在 P2d-d 范围内:订阅者快照持久化、频道日志格式变更、kill -9 意图持久化、/message/sendbatch、expire、AllowStranger、插件/Webhook/AI 行为,以及任何绕过集群/UID-属主语义的本地捷径——单节点部署依然是一个单节点集群。
核心数据结构:从意图到待处理视图
P2d-d 在internal/usecase/cmdsync中定义了三层数据结构(计划文档中的完整定义):
// ConversationIntent describes one CMD conversation update before it is flushed. type ConversationIntent struct { CommandChannelID string ChannelType uint8 MessageSeq uint64 ActiveAt int64 SenderUID string UserReadSeqs map[string]uint64 } // PendingConversationUpdate is the owner-local buffered update for one CMD channel. type PendingConversationUpdate struct { CommandChannelID string `json:"command_channel_id"` ChannelType uint8 `json:"channel_type"` LastMsgSeq uint64 `json:"last_msg_seq"` ActiveAt int64 `json:"active_at"` UserReadSeqs map[string]uint64 `json:"user_read_seqs"` } // PendingConversationView is the pending overlay for one UID and CMD channel. type PendingConversationView struct { CommandChannelID string ChannelType uint8 LastMsgSeq uint64 ActiveAt int64 ReadSeq uint64 }各字段的语义与校验规则:
CommandChannelID必须是source____cmd形态;非命令频道 ID 会在构建意图时被归一化(追加____cmd后缀);MessageSeq必须非零;NoPersist消息与 seq 为零的实时消息永远不会产生意图;UserReadSeqs只允许包含非空 UID;重复 UID 在入队前折叠;- 只有当发送者出现在解析后的接收者集合中时,发送者 UID 才获得
MessageSeq作为初始读进度,接收者一律从0开始; ActiveAt优先取消息时间戳,否则取注入时钟;测试应注入时钟而不是依赖墙钟时间。
计划中还明确了CommandChannelKey的形状(ChannelID string+ChannelType uint8),并强调所有频道级待处理操作都必须同时以CommandChannelID与ChannelType为键,不能只按频道 ID 操作。值得指出的是,当前仓库的internal/usecase/cmdsync/types.go中CommandChannelKey结构与计划完全一致,但持久化模型已从计划中的CMDConversationState演进为UserCMDChannelMembership(见下文"仓库现状"章节)。
意图构建器的规则
BuildConversationIntent(message, recipientUIDs, now)是意图的唯一入口,负责:
- 校验消息是否为 durable 的 CMD 投影消息;
- 把
source归一化为source____cmd; - 去除空 UID 与重复 UID;
- 仅当发送者在归一化 UID 集合中时,为发送者分配
MessageSeq读进度; - 空接收者集合返回"无意图"。
计划中的测试TestBuildConversationIntentUsesScopedUIDsAndSenderReadSeq展示了完整语义:对一条ChannelID: "g1____cmd"、MessageSeq: 9、FromUID: "u1"、SyncOnce: true的组频道消息,输入接收者["u2", "u1", "u2", ""]后,得到的意图UserReadSeqs应为{"u1": 9, "u2": 0}——即发送者自读、接收者零起点、重复与空 UID 被折叠。另有TestBuildConversationIntentNormalizesSyncOnceSourceChannel验证"g1"会被归一化为"g1____cmd",以及TestBuildConversationIntentRejectsNoPersistZeroSeqOrEmptyRecipients验证NoPersist、零 seq、空接收者均被拒绝。
UID-owner 待处理更新器:合并、冲刷与文件恢复
接口与选项
计划为待处理更新器定义了 owner-local 生命周期组件:
type ConversationUpdater interface { Start() error Stop() error PushIntent(ctx, intent) error ListPending(ctx, uid, limit) []PendingConversationView MarkSynced(ctx, uid, commandChannelKey, throughSeq) error Flush(ctx) error }其构造选项(ConversationUpdaterOptions)包括持久化Store、DataDir、FlushInterval、FlushBatchSize、ShardCount、注入时钟Now与日志器Logger。存储接口在types.go中扩展为三条契约:
type StateStore interface { ListCMDConversationActive(ctx context.Context, uid string, limit int) ([]metadb.CMDConversationState, error) AdvanceCMDConversationReadSeq(ctx context.Context, patches []metadb.CMDConversationReadPatch) error UpsertCMDConversationStates(ctx context.Context, states []metadb.CMDConversationState) error } type ConversationPendingStore interface { ListPending(ctx context.Context, uid string, limit int) []PendingConversationView MarkSynced(ctx context.Context, uid string, key CommandChannelKey, throughSeq uint64) error }新增UpsertCMDConversationStates的目的在于:/message/syncack可以为"仅存在于待处理中"的 CMD 频道先创建 durable 读进度,再移除待处理条目。
分片与倒排索引
实现细节上,更新器按命令频道键分片(fasthash.Hash(commandChannelID) % shardCount或本地哈希助手)以降低锁竞争;每个分片维护两个结构:
pendingByChannel[channelKey] -> PendingConversationUpdate userIndex[uid] -> set(channelKey)userIndex复刻了旧版缓存的形态,让/message/sync可以按单个 UID 发现未冲刷的 CMD 频道,而无需扫描整个缓冲。
合并规则(单调、幂等)
同一命令频道上的多条意图在缓冲内按单调最大值合并:
LastMsgSeq = max(existing.LastMsgSeq, intent.MessageSeq);ActiveAt = max(existing.ActiveAt, intent.ActiveAt);- 每个 UID 的
UserReadSeqs[uid] = max(existingReadSeq, newReadSeq); - 接收者的
0更新绝不能回退发送者或已 ack 的读进度。
幂等性保证了 request-scoped 直接意图路由与 delivery observer 重试可以重叠,而不会产生重复的未读状态。计划测试TestPendingUpdaterCoalescesByCommandChannelAndUID验证了两条意图(seq 5 与 seq 7)合并后LastMsgSeq=7、ActiveAt=70、ReadSeq=5的结果。
冲刷与失败语义
冲刷把待处理 UID/频道条目转换为CMDConversationState:
state := metadb.CMDConversationState{ UID: uid, ChannelID: update.CommandChannelID, ChannelType: int64(update.ChannelType), ReadSeq: readSeq, ActiveAt: update.ActiveAt, UpdatedAt: u.now().UnixNano(), }冲刷规则非常强调"整批失败/整批成功"的一致性:
- 以有界批次通过既有的全有或全无
UpsertCMDConversationStates(ctx, []state) errorAPI 写入; - 一批成功后才移除该批包含的所有 UID/频道条目;同一命令频道上其他仍待处理的 UID 不得被一并移除;
- 某批失败时,该批所有条目保持待处理并返回错误——因为存储 API 不暴露逐行结果,绝不能假称逐行成功;
- 若前面的批次已成功而后续批次失败,只移除已成功批次条目;
Stop()停止 ticker 后执行最后一次有界Flush(context.Background()),再把剩余待处理更新保存到文件。
计划中的两个测试TestPendingUpdaterKeepsWholeFailedFlushBatch与TestPendingUpdaterRemovesSuccessfulFlushBatch分别验证了失败保留与成功移除的对称语义。
文件保存/加载与优雅恢复
待处理文件路径为<DataDir>/conversationv2/cmd_conversation_updates.json:
- 保存:把所有分片的更新序列化为
[]PendingConversationUpdate,先写入cmd_conversation_updates.json.tmp,Close后再os.Rename原子替换,避免半写文件覆盖上次完整文件; - 加载:在
Start()进入冲刷循环之前加载,成功后删除文件; - 解码失败:把文件重命名为
cmd_conversation_updates.json.bad(或带时间戳的.bad),记录日志后返回 nil,把坏文件留给运维排查,绝不 panic。
TestPendingUpdaterSaveLoadRoundTrip验证了 Push → Stop(保存)→ Start(加载)后ListPending结果一致的完整闭环。重要边界:这仍不保证 kill -9 前新接收的内存意图被持久化——P2d-d 遵循旧版优雅停机持久化模型,完整的崩溃安全恢复需要另行设计意图 WAL 或复制副作用日志(见 P2d-d 设计文档 的 Rollout Notes)。
/message/sync待处理覆盖层与/message/syncack清理
Sync:合并持久化状态与待处理状态
cmdsync.App.Sync在 P2d-c 的持久化CMDConversationState列表基础上,合并 owner-local 待处理视图。合并算法为:
effectiveReadSeq := maxUint64(maxUint64(persisted.ReadSeq, persisted.DeletedToSeq), pending.ReadSeq) activeAt := maxInt64(persisted.ActiveAt, pending.ActiveAt)关键规则:
- 待处理接收者读进度
0只负责"激活频道",不得覆盖持久化的读进度; - 待处理发送者读进度等于
MessageSeq时,在没有持久化状态的情况下防止发送者自己的 CMD 消息被当作未读返回; - 待处理表明频道活跃,但按
effectiveReadSeq + 1拉取不到任何新消息时,不为该频道创建 sync record; - 持久化候选与待处理候选共享 P2d-c 的确定性排序(
activeAt降序、ChannelType、CommandChannelID)与全局 limit 规则,必须在拉取足够满足全局响应上限的消息之前决定候选顺序; - 普通
/conversation/sync绝不使用此待处理缓冲。
计划中的TestAppSyncMergesPendingCMDConversationOverlay与TestAppSyncUsesMaxPersistedAndPendingReadSeq分别验证了"合并后返回频道消息"与"读进度取持久化/待处理最大值(5 vs 0 → fromSeq=6)"的语义。
SyncAck:先落盘读进度,再清理待处理
/message/syncack的清理算法(计划中的完整清理块)强调一个核心契约:只有在 durable 读进度已存在或刚成功 upsert 之后,才允许清理待处理。步骤为:
- 从 sync records 构建有效读补丁(
validRecords); - 对既有 durable 行调用
AdvanceCMDConversationReadSeq(ctx, patches); - 若
a.pending != nil,按 UID 列出待处理视图并构建(CommandChannelID, ChannelType)查找表; - 对每个"有匹配待处理视图"的有效 record,调用
UpsertCMDConversationStates写入至少包含UID、ChannelID、ChannelType、ReadSeq=LastReturnedMsgSeq、ActiveAt=pending.ActiveAt、UpdatedAt=now的状态——当上一次 advance 因频道仅存在于待处理中而 no-op 时,这一步创建 durable 行,同时避免把 ack 时间伪装成消息活跃时间; - 只有 upsert 成功后才调用
MarkSynced清理该 UID/频道; - durable upsert 失败时不调用
MarkSynced,直接返回错误,让待处理条目继续作为可重试的进度来源; MarkSynced在 durable 进度已存在后失败,仅记录告警并返回 nil——清理是 best effort,因为读进度已经 durable。
清理规则本身:ackSeq >= pending.LastMsgSeq时从该待处理更新中移除该 UID;该频道待处理更新不再有 UID 时移除频道与索引条目;ackSeq低于LastMsgSeq时保留待处理条目供后续 sync。持久化读进度推进必须保持单调,待处理清理绝不能成为唯一的读进度来源。相关测试包括TestAppSyncAckCleansPendingAfterPersistedAdvance、TestAppSyncAckTreatsPendingCleanupFailureAsBestEffort与TestAppSyncAckPersistsPendingOnlyReadProgressBeforeCleanup。
UID-owner 路由与节点 RPC:意图如何到达属主节点
路由分层
意图在写入待处理缓冲之前必须路由到 UID 属主节点,因为待处理缓冲必须与为该 UID 提供/message/sync的节点保持一致。路由规则(设计文档明确定义):
slotID := cluster.SlotForKey(uid) owner := cluster.LeaderOf(slotID)cmdConversationIntentRouter.PushIntent的实现要点:
- 路由前先经 cmdsync 辅助函数校验意图,
MessageSeq=0、空/非命令CommandChannelID、ChannelType=0、空 UID map 一律在任何本地或远端写入之前拒绝; - 把
UserReadSeqs按ownerNodeID(uid)(SlotForKey+LeaderOf)分组; - 每个属主组克隆一份只含该组 UID 的意图;
- 本地组调用
local.PushIntent,远端组调用remote.PushCMDConversationIntent; - 仅当所有组都成功时返回
fullyAccepted=true; - 若某组失败,返回
false与合并错误——重复重试是安全的; - 若远端组返回
cmdsync.ErrConversationIntentStaleOwner(属主已迁移),重新解析该组 UID 属主并重试一次,仍失败则返回false与合并错误。
owner 校验包装与 stale-owner 语义
节点 RPC 接收侧使用ownerValidatingCMDIntentSink维护 owner-local 不变量:对意图UserReadSeqs中的每个 UID 验证ownerNodeID(uid) == localNodeID,不满足则返回cmdsync.ErrConversationIntentStaleOwner,且不本地存储部分非法意图。调用方(路由)捕获该哨兵错误后可以重新解析属主并重试。节点 RPC 层把该哨兵映射为可重试状态(如rpcStatusStaleOwner = "stale_owner"),客户端把该状态转换回满足errors.Is(err, cmdsync.ErrConversationIntentStaleOwner)的错误,从而保留重试语义。缺失 provider 或意图畸形(MessageSeq=0、空/非命令频道、ChannelType=0、无任何非空 UID)时返回rpcStatusRejected。
二进制编解码的确定性
RPC 请求中的意图通过internal/access/node/cmdsync_codec.go追加/读取。UserReadSeqsmap 的编码必须确定性:先对 UID 键排序再追加,以保证网络往返字节一致:
uids := make([]string, 0, len(intent.UserReadSeqs)) for uid := range intent.UserReadSeqs { uids = append(uids, uid) } sort.Strings(uids) dst = appendUvarint(dst, uint64(len(uids))) for _, uid := range uids { dst = appendString(dst, uid) dst = appendUvarint(dst, intent.UserReadSeqs[uid]) }相关 RPC 测试覆盖了编解码往返、缺失 provider 拒绝、畸形意图拒绝、stale-owner 可重试状态以及客户端推送到远端属主(TestCMDConversationIntentBinaryCodecRoundTrip、TestCMDIntentAdapterMissingProviderReturnsRejectedStatus、TestCMDIntentAdapterStaleOwnerIsRetryable、TestCMDIntentClientPushCallsRemoteOwner等)。
两类意图来源:request-scoped 直投与 delivery UID 页观察者
来源一:request-scoped durable 发送直投
对 durable request-scoped CMD 发送,SendCommand.RequestSubscribers本身就是精确的业务接收者集合,因此最安全的旧版兼容路径是在消息通道内直接构建意图,而无需等待异步 delivery 重新发现同一份请求局部接收者列表:
message.App.sendDurable -> append to temp____cmd channel -> build CMD conversation intent from RequestSubscribers -> route intent to UID owners -> submit committed delivery envelope with MessageScopedUIDs and intent-submitted flag发送结果不能仅仅因为"append 成功后 best-effort 会话意图路由失败"而变成虚假的 durable 失败:意图路由失败只记录日志并返回非致命副作用结果,durable append 成功仍是消息写入的事实来源。为此引入内存事件/信封元数据标志:
CMDConversationIntentSubmitted bool规则(设计文档):
- 仅当 request-scoped 意图的所有 UID-owner 分区都被本地待处理更新器或远端属主 RPC 接受时,才置位该标志;
- 任一 owner 分区失败则保持
false,让 delivery UID 观察者基于MessageScopedUIDs做第二次 best-effort 尝试; - 部分成功可接受,因为已接受分区的重复提交是幂等的;
- 该标志不写入频道日志,不期望在 committed replay 后存活;
- delivery 观察者仅在
CMDConversationIntentSubmitted=true时跳过 request-scoped 意图生成; - P2d-d 不放松 request-scoped committed delivery 提交的既有严格性:append 后若 message-scoped delivery 提交失败,发送路径仍返回既有分发错误,只有 CMD 会话意图副作用是非致命的。
internal/usecase/message只调用 app 提供的意图 sink(CMDConversationIntentSink接口),不包含任何集群路由逻辑,这符合分层边界。
来源二:delivery 解析器 UID 页观察者
对于普通 durable CMD 频道,接收者集合来自 delivery 解析器路径而非独立的提交后订阅者扫描。在 app delivery 解析器组装附近新增窄观察者接口:
type resolvedUIDPage struct { Envelope deliveryruntime.CommittedEnvelope UIDs []string } type resolvedUIDObserver interface { OnResolvedUIDPage(context.Context, resolvedUIDPage) }关键约束是在 presence 扩展之前通知——因为 CMD 离线同步也需要离线用户,而在线RouteKey会丢弃离线用户:
subscriber/tag UID page -> observer receives UIDs -> presence authority expands online routes -> delivery runtime pushes online packets观察者调用是副作用,绝不能使 delivery 解析失败:观察者实现可以入队、记录失败、上报指标,但 delivery 代码不应根据观察者成功与否分支;重试应实现于意图路由或待处理更新器队列内部,而不是向 delivery actor 返回错误。notifyResolvedUIDPage辅助函数明确跳过 nil 观察者、空 UID 页以及"已完全提交的 request-scoped 意图"(env.CMDConversationIntentSubmitted && len(env.MessageScopedUIDs) > 0)三种情况。
对 tag 式 delivery:使用tagDeliveryResolver当前正在扩展的 tag 本地 UID 页;follower 只收到本地分区时只提交该分区意图;本地节点同时是该分区 UID 属主时由路由直接写本地待处理;拓扑变化或分区包含属主已迁移的 UID 时,路由重新解析属主并正确转发。对本地解析器回退路径:使用SubscriberResolver.NextPage返回的 UID(presence 扩展前),按 UID 属主完全相同的方式路由。钩子对非 CMD 消息与NoPersist消息必须是 no-op。
测试TestTagDeliveryResolverNotifiesUIDObserverBeforePresenceExpansion验证了观察者在 presence 扩展前收到包含离线/在线用户的 UID 页;TestDeliveryUIDObserverSkipsWhenRequestScopedIntentAlreadySubmitted验证已提交意图不会二次通知。回退测试还要求tagDeliveryResolver在 tags/subscribers 为空时委托内联localDeliveryResolver且 UID 观察者仍被调用,防止回退路径丢失 CMD 意图。
事件元数据与信封传播
CMDConversationIntentSubmitted标志需要在整条 committed 链路上传播(Task 6 的修改清单):
internal/contracts/messageevents/events.go的MessageCommitted增加该布尔字段,Clone()必须保留;internal/runtime/delivery/types.go的CommittedEnvelope增加同名字段;internal/usecase/delivery/submit.go在MessageCommitted与运行时信封之间拷贝;internal/access/node/delivery_submit_codec.go引入deliverySubmitRequestMagicV3 = [...]byte{'W', 'K', 'D', 'C', 3}:当len(MessageScopedUIDs) > 0或标志为 true 时编码 V3(V2 字段 + 一个尾部布尔);V1 解码得到空 scoped UIDs 与 false 标志,V2 解码 scoped UIDs 与 false 标志,V3 两者都解码——保持既有 V1/V2 兼容覆盖。
组合装配、生命周期与 committed replay 清理
app 组合根与生命周期
计划要求把internal/app/app.go中旧的cmdSyncProjector字段/生命周期标志替换为具体运行时字段:
cmdConversationUpdater *cmdsync.ConversationUpdater cmdConversationIntents cmdConversationIntentRouter cmdConversationUpdaterOn atomic.Bool生命周期组件名从appLifecycleCMDSyncProjector = "cmdsync_projector"替换为appLifecycleCMDConversationUpdater = "cmd_conversation_updater";停止顺序应让 delivery 先停止再执行最终待处理保存(若生命周期按启动逆序停止,则应在 delivery runtime 之前启动更新器,以便它在 delivery 排空后停止)。
internal/app/build.go的组装要点:
app.cmdConversationUpdater = cmdsync.NewConversationUpdater(cmdsync.ConversationUpdaterOptions{ Store: app.store, DataDir: cfg.Node.DataDir, FlushInterval: cfg.Conversation.ActiveHintFlushInterval, // or a local default FlushBatchSize: cfg.Conversation.ActiveHintFlushBatchSize, Logger: app.logger.Named("cmdsync.pending"), }) cmdIntentRouter := cmdConversationIntentRouter{local: app.cmdConversationUpdater, remote: app.nodeClient, cluster: app.cluster, localNodeID: cfg.Node.ID, logger: app.logger.Named("cmdsync.intent")} app.cmdConversationIntents = cmdIntentRouter随后把待处理更新器传入cmdsync.New的Pending选项,把观察者注入tagDeliveryResolver与回退localDeliveryResolver构造器(tagDeliveryResolver.BeginResolve内联创建回退解析器时必须拷贝uidObserver),把路由传入message.New的CMDConversationIntents选项,把ownerValidatingCMDIntentSink传入accessnode.New选项。旧app.cmdSyncProjector构造块与 committed fanout 订阅者被移除——fanout 只保留 delivery dispatcher。
committed replay 只走 delivery
计划明确删除独立的CMDSync.ProjectCommitted订阅者扫描 replay:committed_replay.go移除committedReplayCMDSync接口与CMDSync配置字段,不再调用ProjectCommitted,完全依赖重放的 delivery 触发 delivery UID 观察者来为普通 CMD 消息重新产生意图。文档保留一个明确的局限:request-scoped replay 没有MessageScopedUIDs时仍是 best effort,本阶段不通过快照表修复(这正是不引入订阅者快照持久化的设计取舍)。
仓库现状:从 CMDConversationState 到 UserCMDChannelMembership 的演进
值得注意的是,当前仓库中 P2d-d 计划描述的部分组件已经历后续迭代。internal/usecase/cmdsync目录现在由 app.go、types.go、binding.go、records.go 等文件组成,持久化模型从计划中的CMDConversationState(按uid -> readSeq每命令频道维护读进度)演进为UserCMDChannelMembership成员关系模型:持久化表定义在 pkg/db/meta/table_user_cmd_channel_membership.go,主键为(uid, command_channel_id, channel_type),并携带StartSeq、AckSeq、Tombstone、TombstoneAt、UpdatedAt等字段,把"CMD 频道发现"与"ack 进度"合并为一行成员关系;internal/usecase/cmdsync/types.go中的StateStore相应提供ListUserCMDChannelMembershipPage、UpsertUserCMDChannelMemberships、AdvanceUserCMDChannelMembershipAcks、TombstoneUserCMDChannelMemberships方法,同时新增 binding.go 的Bind/Unbind命令用于显式创建/墓碑化 CMD 目录行。计划中"意图-待处理-落盘"的核心思想(用已解析接收者而非提交后扫描、先路由到 UID 属主、优雅停机文件恢复、/message/sync未冲刷活跃可见)在这些后续迭代中以成员关系形态继续传承。
HTTP 适配层保持稳定:internal/access/api/message_sync.go 仍暴露POST /message/sync(handleMessageSync)与POST /message/syncack(handleMessageSyncAck),请求体含uid、message_seq(仅兼容接受、不选择状态)、limit,响应把SyncedMessage转换为 legacy 消息格式。app 组装根在 internal/app/wiring.go 的wireCMDSync()中通过clusterinfra.CMDSyncNode构建cmdsyncusecase.New(cmdsyncusecase.Options{CommandChannelSuffix: a.cfg.Message.CMDChannelSuffix, States: store, Messages: store}),其中CommandChannelSuffix配置项(默认空即 legacy 默认后缀)决定命令频道 ID 编解码。
端到端回归与文档同步
Task 9/10 明确要求以下回归测试与文档更新:
- 普通
/conversation/sync忽略 CMD 频道——即使 CMD 待处理更新器对该 UID 有条目; /message/sync对 pending-only 频道只剥离恰好一层____cmd后缀;/message/syncack使用最新 sync generation 并清理待处理,绝不把last_message_seq当作频道选择器;NoPersist命令风格消息不调用意图 sink、不创建待处理条目;- request-scoped durable 发送的部分 owner 路由失败通过 delivery observer 重试,且已接受分区保持幂等;
- 聚焦包测试:
go test ./internal/usecase/cmdsync ./internal/usecase/message ./internal/usecase/delivery ./internal/access/node ./internal/app -count=1; - 存储/代理健全性:
go test ./pkg/slot/meta ./pkg/slot/fsm ./pkg/slot/proxy -count=1; - 导入边界:
go test ./internal -run TestInternalImportBoundaries -count=1。
文档层面,docs/raw/send-path-business-logic-diff.md 的"P2d-d 实施后状态(2026-05-14)"章节确认了上述行为全部落地:request-scoped durable send 在 append 成功后用RequestSubscribers构建意图并按 UID owner 路由;普通 CMD delivery 在解析出 UID 页后、presence 扩展前构建意图;ConversationUpdater按 command channel + channel type 合并并维护 UID 索引;/message/sync把 pending overlay 与已落盘CMDConversationState合并;/message/syncack对 pending-only 频道先 upsert durable 读进度再清理;优雅停机把未 flush 的 pending updates 保存到DataDir/conversationv2/cmd_conversation_updates.json并在启动时恢复(启动加载后保留文件直到后续 durable flush/save 成功,缩小恢复窗口内再次崩溃的丢失面,但 kill -9 前新接收的内存意图仍不保证持久化);committed replay 只重放 delivery/conversation,不再运行独立 CMD subscriber scan/projector。仍未恢复的差异包括在线 cmd 专用systemcmdonline、普通临时频道投递、/message/sendbatch、expire、plugin/webhook/AI 钩子等,继续延后。
分层与边界总结
P2d-d 的最终分层(设计文档"Layering")是理解代码归属的钥匙:
internal/usecase/cmdsync:意图模型、构建器、待处理更新器、sync 覆盖层、ack 清理;internal/app:UID-owner 路由、生命周期装配、delivery 观察者装配、集群/节点依赖;internal/access/node:远端属主意图提交的薄 RPC 适配器,只做校验与传输映射,不承载业务规则;internal/usecase/message:仅为 request-scoped durable 发送调用 app 提供的意图 sink,不含集群路由逻辑;internal/runtime/delivery:不承载 CMD 业务规则,如需触碰只通过 app 装配暴露泛化 UID 页观察者钩子。
全阶段不引入新的全局服务对象。失败处理遵循旧版 best-effort 会话副作用语义:缺失 UID 属主 leader 时仅在路由有重试队列时保留意图,否则记录并作为副作用失败丢弃;远端 RPC 失败在调用方已异步时有界重试;待处理队列满则记录、上报指标并丢弃副作用,而不是无限阻塞 delivery actor;冲刷失败保留内存待处理并下个 tick 重试;停止保存失败清晰记录、不 panic;加载解码失败重命名.bad供运维检查。这套设计在"不新增订阅者快照表"的前提下,把 CMD 会话更新恢复到了旧版 conversation intent 语义,是理解 WuKongIM 发送链路从 P2d-c 投影模型演进到意图-待处理-成员关系模型的关键文档。
- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
相关推荐
WuKongIM 进程级引导(App Bootstrap)实现剖析:从 internal/app 到 cmd/wukongim 的集群优先组装路径
WuKongIM 进程级引导(App Bootstrap)实现剖析:从 internal/app 到 cmd/wukongim 的集群优先组装路径 导读 本文基
即时通讯后端WuKongIM MQTT 本地 Owner 到期清扫(Owner Sweeping)机制解析
WuKongIM MQTT 本地 Owner 到期清扫(Owner Sweeping)机制解析 本文以 WuKongIM 仓库中的设计契约文档 docs/spe
即时通讯后端WuKongIM MQTT 订阅编排:基于 Session 修订 CAS 的订阅意图协调机制解析
WuKongIM MQTT 订阅编排:基于 Session 修订 CAS 的订阅意图协调机制解析 导读 本文聚焦 WuKongIM 中 MQTT 订阅能力的关键
即时通讯后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考