☰
WuKongIM MQTT 离线排空(Offline Drain):中断 UNSUBSCRIBE 后的原子元数据契约
2026/10/15 12:12:25 网站建设 项目流程
  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

本文聚焦 WuKongIM 中 MQTT 会话的“离线排空”元数据契约:当客户端连接已断开、而订阅移除(UNSUBSCRIBE)流程尚未完成时,系统如何原子地释放未准入(unadmitted)的积压消息。本文详细解读其设计动机、七类故障清单、MQTTWindowAdvance与MQTTCursorCancelInit两个核心操作的离线语义、源码级实现与测试验证,并说明该契约与后续后台恢复链路(背景源排空、订阅收尾)的衔接边界,适合在 WuKongIM 仓库中从事 MQTT 存储、Slot 状态机与恢复编排的开发者阅读。

一、为什么需要“离线排空”契约

在 WuKongIM 的 MQTT 实现中,一次 UNSUBSCRIBE 并不只是简单删除订阅,它要处理“已经计费但尚未投递”的积压(backlog)消息:这些消息占用了会话配额(quota),必须在订阅关闭后释放。问题在于,UNSUBSCRIBE 可能在连接断开后才完成——连接已关闭,但订阅仍停留在Removing(移除中)阶段,积压消息无人释放。

原文档明确指出此前窗口/取消初始化命令(Window Advance / CancelInit)都要求 Session 处于 Active 状态,这直接阻塞了“连接关闭后的后台恢复”这一使用场景。因此本步骤的目标是:为已关闭的订阅意图(closed intent)在 Offline 状态下提供原子化的元数据操作契约。需要强调的是,它提供的是“原子变更能力”,而非自动恢复本身——真正的后台恢复编排(背景排空 worker、发现、订阅收尾)仍属于后续工作。

二、实现前的故障清单(Failure Inventory)

原文档在动手前系统性地枚举了 7 类必须防住的故障,它们既是实现约束,也是后续测试用例的来源:

  1. 离线关闭的订阅无法释放积压:Offline 状态下已关闭的订阅既不能释放 backlog,也无法创建空的取消游标(cancellation cursor),导致恢复 worker 无法续接持久化意图(durable intent)。
  2. 放宽 Active 约束的副作用:如果简单放宽 Active 限制,就会顺带允许离线准入(admission)、伪造 PUBACK、跳过在线订阅积压、或初始化一个可投递的取消游标。
  3. 旧元数据污染后继者:较旧的 Session 生命周期(lifetime)、Owner 元组或修订号(revision)若被应用到后继会话,会破坏隔离。同一订阅代数(generation)必须保留授权语义;只有严格更新的订阅代数才能见证旧意图的关闭。
  4. 清理操作越界:清理不得擦除 inflight 交换或 ACK 空洞、不得释放不精确的合格计费(qualified charges)、不得跨越多个计费回执(accounting receipt)、不得重置已有游标。
  5. 丢失响应被重复应用:一次丢失的响应若被应用两次,会造成 Session/游标或 inflight 状态的局部冲突变更。因此既有的摘要(digest)/修订号检查和原子批处理必须保留。
  6. 离线清理不得改变生命周期语义:不得改变过期(expiry)/租约(lease)/遗嘱(Will)语义,不得重新激活(reactivate)Session,不得通过错误契约排空已显式结束的生命周期。
  7. 混合版本的兼容性陷阱:一个模式兼容的命令不能想当然地认为在混合二进制集群中安全——因为复制式 FSM(Slot FSM)的接收语义可能不同。此路径要求集群内写入方版本匹配,不引入混合版本回退。

设计上还强调:测试必须使用真实的元数据批处理和读取来证明原子状态变更与负向用例(negative cases),且应在生产变更之前完成。产品级后台发现、关闭意图编排、无游标源证明与最终订阅收尾仍然缺失;现有的进程配额测试仅作为回归证据。

三、已实现的元数据行为(核心契约)

3.1 离线 Window Advance:多一次点读换取安全释放

原有MQTTWindowAdvance(窗口推进)操作现在允许 Offline Session 执行,但前提是:在同一原子批处理中额外读取游标对应 topic 的订阅记录。具体约束为(对应 pkg/db/meta/mqtt_window.go 的 FSM 逻辑):

  • 订阅必须处于Removing(移除中)或Removed(已移除)阶段,与游标记录的授权版本(AuthorizationVersion)一致;或者订阅代数严格更新(新代数的订阅可以见证旧意图的关闭);
  • 缺失(missing)或较旧(older)的意图直接拒绝该变更;
  • 这次“额外的点读”仅发生在离线推进时;
  • 在线推进(Active)保持原有行为不变,包括正常的 QoS 0 跳过逻辑;
  • 离线 Admit(准入)与 ACK 依旧被拒绝;所有已结束(Ended)Session 的变更依旧被拒绝。

也就是说,离线状态下只有“推进窗口、释放未准入积压”这一条路被放开,准入与确认仍然严格绑定在线连接,防止把离线通道变成伪装的在线写入。

3.2 离线 CancelInit:只创建缺失的空游标

CancelInit(取消初始化)操作同样支持 Offline,允许的条件是:同代数的已关闭意图(closed intent),或严格更新的替换见证(replacement witness)。其语义被严格限定(对应 pkg/db/meta/mqtt_delivery_cursor.go 的 FSM 分支):

  • 只创建缺失的空取消游标(empty cancelled cursor),绝不重置已有游标,也绝不追加积压;
  • 这为“中断的、尚无游标的订阅准备流程”提供了可取消的持久化出口。

3.3 两条操作共享的硬性约束

无论是离线 Advance 还是离线 CancelInit,都必须完整保留既有护栏:

  • 精确的 Session 生命周期、Owner 元组(generation/node/boot)、修订号检查一个不少;
  • 摘要重试(digest retries)与原子 Session/游标联合更新照旧;
  • 这些操作不查询墙钟(wall time)、不延长过期、不改变 Will 状态、不重新激活 Session、不推断源保护(source protection)、不授权任何网络副作用。

换言之,它们只做“元数据账务层面的收尾”,一切生命周期与网络权限的副作用都被隔离在契约之外。

3.4 合格推进(Qualified Advancement)的计费边界

对于启用合格计费(AccountingVersion 1)的游标,离线推进必须遵守既有计费契约(见 pkg/db/meta/mqtt_accounting.go 中“每张回执最多覆盖 256 个位置、向前单向链式”的MQTTAccountingRange设计):

  • 单次变更最多消耗一个已有且有界的计费区间,且扣减的是精确的原始计费额(exact original charges);
  • 完整保留 PacketID、投递顺序(delivery order)、消息体引用(body reference)、inflight 计数/字节数以及连续的 ACK 空洞(contiguous ACK gaps);
  • 丢失的应答(lost replies)仅当精确的、变更后 Session 修订号仍然匹配时,才允许复用原始摘要(digest)进行幂等重试。

3.5 无模式变更,但要求版本匹配的二进制

本契约不新增任何表、索引、列、JSON 成员、命令 ID、操作 ID 或编码格式。变更集中在Slot FSM 的接收语义上,因此:

  • 所有参与节点必须运行匹配的二进制(matching binaries);
  • 这不构成“混合版本复制是安全的”的证据。

四、源码级实现解读

4.1 窗口操作的 FSM:MutateMQTTWindow

在 pkg/db/meta/mqtt_window.go 中,MutateMQTTWindow是窗口操作的唯一入口,MQTTWindowAdvance定义为操作码 3(MQTTWindowAdmit=1、MQTTWindowAck=2、MQTTWindowAdvance=3)。其提交流程的关键点:

  1. 先做权威校验:加载 Session 行,校验 Session 代数、OwnerGeneration/OwnerNodeID/OwnerBootID/ConnectionID 与连接围栏(connection fence)完全一致;
  2. 幂等重试:若游标修订号等于ExpectedRevision+1且摘要匹配,则返回MQTTWindowUnchanged,精确复现上次的 PacketID 与 DeliveryOrder——这是丢失应答重试的安全基础;
  3. 离线分支(上面 3.1 节):当session.State != MQTTSessionActive时,只有MQTTWindowAdvance且 Session 为MQTTSubscriptionOffline才继续,并且必须加载订阅行验证关闭意图;
  4. 变更前校验:任何被拒绝的条件请求都不得留下部分变更的链表,因此先ValidateMQTTDeliveryCursor/ValidateMQTTSession,通过后才 stage 计费回执、inflight 行与游标/Session 行,实现原子提交。

4.2 取消游标操作:MutateMQTTDeliveryCursor

在 pkg/db/meta/mqtt_delivery_cursor.go 中,MQTTCursorCancelInit被定义为游标操作码 3(MQTTCursorInit=1、MQTTCursorAccount=2、MQTTCursorCancelInit=3、MQTTCursorAccountQualified=4)。其校验要点:

  • 操作码级校验:MQTTCursorInit与MQTTCursorCancelInit不允许携带任何 AddedMessages/AddedBytes(ValidateMQTTDeliveryCursorMutation中强制AddedMessages==0 && AddedBytes==0);
  • 离线关闭意图判定:当操作码为CancelInit时,要求订阅代数>=游标键中的订阅代数;同代数时必须AuthorizationVersion一致且阶段>= Removing;严格更新的订阅代数可作为替换见证;
  • 游标已存在则拒绝(“只创建缺失的空游标”);
  • 其余操作(Account/AccountQualified)仍要求同代数、同授权版本、Preparing/Active 阶段——准入路径在离线状态下没有被放开。

4.3 订阅阶段机:MQTTSubscriptionStage

关闭意图判定的基础是订阅阶段机(pkg/db/meta/mqtt_subscription.go):

  • MQTTSubscriptionPreparing = 1(准备中)
  • MQTTSubscriptionActive = 2(活跃)
  • MQTTSubscriptionRemoving = 3(移除中)
  • MQTTSubscriptionRemoved = 4(已移除)

阶段迁移也受限制:Removed不得回退到Preparing;非活跃/已结束的 Session 只能向Removing/Removed推进。离线排空只认Removing/Removed或更新代数的替换见证,正是对“同订阅代数保留授权、新代数见证旧意图关闭”这一原则的落地。

4.4 与 Usecase 层的关系:SourceDrain

元数据契约只是最底层。在 usecase 层,internal/usecase/mqttsession/source_drain.go 的SourceDrain.Seal运行在“当前已准入的 Owner”之下,逐槽位(Slot)执行:读取绑定 → 读取当前关闭订阅(或更新代数)→ 固定 Session/游标 → 密封绑定终点(end)→ 用一次MQTTWindowAdvance释放未准入积压。每次调用最多 6 次点读、2 次绑定 CAS 写入、1 次取消初始化、1 次窗口变更,剩余工作以ErrSourceDrainPending返回给后续 reconcile(该错误定义在同文件首部)。文档明确:SourceDrain.Seal仍要求本地实时执行(local live execution),离线场景下由ReconcileClosed(见下文第七节)接管。

五、测试验证:原子性与负向用例

原文档要求“测试使用真实元数据批处理和读取”。仓库中对应的测试文件 pkg/db/meta/mqtt_offline_drain_test.go 覆盖了四条核心路径:

  1. TestMQTTOfflineDrainPreservesExchangesAndQualifiedCharges:先构造合格计费游标与一次 Admit(PacketID 101、30 字节),关闭订阅意图(分无替换/有替换两种),断线进入 Offline;随后验证:错误字节数(ReleasedBytes--)的 Advance 返回MQTTWindowConflict、正确 Advance 返回MQTTWindowApplied、重复提交返回MQTTWindowUnchanged;同时断言 Pending 计数、OutboundInflight、OfflineExpiresAtMS、WillGeneration 均未变,且 inflight 记录前后完全一致(before == after)。
  2. TestMQTTOfflineDrainRejectsLiveIntentAndStaleAuthority:对active-intent / owner / node / boot / connection / revision / lifetime / future-subscription / ended / admit十种模式逐一验证MQTTWindowConflict且账务状态零变化——对应第二节故障清单第 2、3、6 条。
  3. TestMQTTOfflineDrainKeepsOneAccountingRangePerMutation:验证单次变更最多消耗一个计费区间——跨越区间边界(Through=108,越过 105 起始的区间)被拒绝;按 104 先释放一段后,剩余区间再释放才成功,且 Pending 归零后游标CompletedThrough精确等于 108。
  4. TestMQTTOfflineCancellationInitOnlyForClosedIntent:对preparing / removing / replacement / authorization / owner / revision / ended七种模式验证:只有removing与replacement两种关闭意图能应用CancelInit,且新游标满足StartAfter == CompletedThrough、PendingMessages == 0;之后任何对同一游标的 Init/Account/重复 CancelInit 均被拒绝。

此外 pkg/db/meta/mqtt_cursor_cancel_test.go 的TestMQTTCursorCancelInitRequiresClosedAdmissionAndNeverResetsProgress独立验证了 CancelInit“不重置进度、不开放准入”的语义(preparing/active 阶段一律 Conflict;成功后 Attempt 推进Through也 Conflict)。

六、证据与边界(Evidence and Limits)

原文档给出了可复现的量化证据,证据报告(含命令、冻结上下文、实现哈希与 8 个 JSON 产物)保存在 docs/reports/mqtt-offline-drain.json:

验证项结果
实现前聚焦用例RED:离线 Advance 与 CancelInit 返回 Conflict
实现后聚焦 race 套件(pkg/db/meta的 OfflineDrain/Cancellation/Window/Qualified/CursorCancel)PASS,5.114s
完整元数据 race 套件(pkg/db/meta)PASS,37.962s
MQTT usecase race 套件(internal/usecase/mqttsession)PASS,95.513s
既有 8 个进程配额/完成场景(单节点与三节点 256 哈希槽集群,test/e2e/mqtt/quota)PASS,207.193s
flow-doc-contracts(go run ./scripts/flowcheck --mode check)87 个合规文件、0 个无效、既有 9 条警告

测试覆盖的语义包括:精确扣减(exact debit)、保留 inflight 身份与 ACK 空洞、Offline 过期时间不变、拒绝在线意图/过期权威/已结束生命周期、以及计费区间边界。需要特别强调原文档的边界声明:8 个进程场景只是回归证据,它们并不证明尚未接线的“中断 UNSUBSCRIBE 路径”已经可用——该路径的完整过程级验收仍然是未完成项。

七、后续编排:从元数据契约到后台恢复

本契约解决的是“原子元数据能力”,真正的恢复编排属于后续组成(Next required composition):

  1. 背景源排空(Background source draining)必须:通过路由读取(routed reads)派生全新的精确父级与关闭意图;用取消/修订号检查限定每一个副作用;复用源保护(source protection)与持久化的密封/区间检查点;绝不伪造本地 Owner,也绝不激活 Offline Session 来复用前台路径。
  2. 源排空之后,UID 检查点与订阅收尾仍需要权威、有界的延续逻辑,包括中断的无游标准备(cursorless preparation)的恢复。
  3. 应尽量复用既有的消费队列(consumer cohort)与待处理意图索引;完整的“中断 UNSUBSCRIBE 过程验收”仍然悬而未决。

值得注意的是,后续文档 docs/specs/mqtt-closed-source-maintenance.md 显示:SourceDrain.ReconcileClosed已通过既有的 consumer cohort 中的与 Owner 无关的入口使用这些原语——它在构造时可选Owners注册表、独立捕获精确父级 Owner/UID、每轮最多释放一个计费区间且不声明移除完成。这印证了本文契约正是为“无实时 Owner 的后台维护”铺路的第一块基石;而最终订阅/UID 收尾与首次绑定前的意图发现仍是后续工作。

八、总结

mqtt-offline-drain契约在 WuKongIM 的 MQTT 存储层解决了三个关键问题:离线关闭意图的积压释放、中断准备的可取消持久化、以及计费/游标状态的原子安全。它通过“离线推进必须额外点读订阅关闭证据 + CancelInit 只建空游标”的最小放开,把在线准入、ACK 与生命周期副作用严格隔离在外,并以真实元数据批处理测试锁定了 17 种以上正负向场景。理解这条契约,是理解 WuKongIM 后续 UNSUBSCRIBE 恢复链路(Seal → ReconcileClosed → 订阅/UID 收尾)的必经入口——其边界声明也提醒读者:元数据契约就位 ≠ 中断 UNSUBSCRIBE 已恢复,完整的后台编排与进程级故障验收仍在演进中。

  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

相关推荐

上一篇:CLM-v0.1-8B 局限与路线图:编码器锁定、无生成能力,以及即将发布的多模态 CLM-35B
下一篇:pxpipe 安全模型与部署加固指南:凭据保护、fail-closed 设计与漏洞报告流程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询