# 可靠结果订阅:持久化核心 状态:P3 的存储与服务层已实现并通过本地全量回归,见 [验证记录](reports/phase-3-result-subscription-core.md)。[HTTP/WSS、管理 SDK 与管理示例](api/subscriptions.md) 已接入。[事件连接 SDK 与自动消费器](sdk/event-consumer.md) 已实现,[持久化 Inbox 示例](examples/result-inbox.md) 及消费者强杀回归已完成;本页的存储方法属于服务端内部模块。 ## 1. 已实现范围 结果来源限定为 task.succeeded、task.failed、task.cancelled、task.needs_review。安全重试产生的 task.requeued 不表示最终结果,不进入这个结果流。needs_review 表示需要核查,可能以后再产生权威结果,接入方不能把它当作成功或最终失败。 来源任务状态、原状态事件、不可变结果快照、结果流序号、原执行回执/审计在同一数据库事务中提交。任何一项失败都会回滚,Worker 不会收到“结果确认成功但没有可靠通知记录”的回执。 `orbit_events` 继续保存状态事件;新增 `orbit_result_events` 使用相同事件 ID 保存队列、提交序号和快照。快照包含 schema_version=1、task_id、queue_id、state、revision、result、effect_uncertain 和可空 current_attempt_id。不包含任务输入和租约秘密。结果使用 JSON 文本保存,保留大整数的原始数值文本。来源任务之后发生改变时,不会重新生成已发布的快照。 ## 2. 为什么不能按最大事件 ID 推进 自增 ID、随机 ID 和事件创建时间都不能证明事务提交顺序。如果较小 ID 的事务还没有提交,消费者先看到较大的 ID 并移动游标,就可能永久漏掉较小的事件。 `orbit_event_streams` 为每个命名空间保存 last_sequence。生产事务持有独立流行锁,分配下一个序号并一起提交。后来事务在前一个提交或回滚前,不能获得更大的可见序号。回滚不消耗序号。 消费者读取已经提交的流头,并且只扫描不超过这个位置的日志。读取流头不加锁;订阅创建需要锁流行,以确定“从现在开始”的准确边界。该方案存在命名空间内的短暂串行分配热点,其实际吞吐和锁等待仍须在 P6 验证。 锁顺序: - 任务路径沿用认证/应用额度/队列/任务/尝试/许可的既定顺序,保存结果时最后取得流行锁;不访问订阅行。 - 创建订阅按认证 → 幂等身份 → 队列共享锁 → 流行锁 → 新订阅/审计执行。 - 消费、确认和管理按认证 → 必要的幂等身份 → 订阅 → 交付执行;不再取得任务、队列、应用额度或流行锁。 ## 3. 订阅和授权 每个订阅绑定创建凭据和一个队列,事件类型集合在创建时冻结。名字限 1–128 个 ASCII 字符,以字母或数字开头,其余允许字母、数字、点、下划线和连字符;命名空间内唯一。首版每命名空间最多 64 个订阅,没有公开删除或更换所属凭据的能力。 | 字段 | 语义 | | --- | --- | | from_sequence | 订阅此序号之后的结果;省略时使用创建事务确定的最新提交序号;显式 0 表示从现存结果日志开始 | | types | 四类结果的非空子集;省略或空数组使用全部四类,重复或其他类型被拒绝 | | max_pending | 已物化但尚未确认的交付上限,省略或 0 使用 100,自定义范围 1–1000 | | max_attempts | 每条新交付初始可发送次数,省略或 0 使用 20,自定义范围 1–100 | | cursor_sequence | 已扫描并可靠建立交付记录的位置,**不是消费者已确认位置** | | pending_count | PENDING、LEASED、EXPIRED 的总数;每次有效确认只扣减一次 | | revision | 配置并发版本;物化和积压数量变化不增加该版本 | 新动作独立授权:events:manage、events:read、events:consume。每个操作同时检查凭据有效性、所属应用/命名空间启用、队列范围和订阅所有者。同一命名空间的另一张凭据,也不能读取、领取、确认或管理该订阅。管理权限不能隐含消费权限,任务读取权限不能隐含订阅权限。 bootstrap 新增显式 --event-access,用于签发包含这三项动作的开发凭据;不会给既有凭据自动扩权。轮换后的安全重新绑定列入 P6,当前不能将旧凭据所属订阅自动转移。 创建、暂停/继续、人工重试都有持久化幂等身份及原回执。身份作用域包含所属凭据与目标资源,复合身份使用 SHA-256 编码到 64 字符,避免跨凭据回执泄漏或数据库截断。 ## 4. 从结果日志到交付记录 每次领取在订阅锁下最多扫描 100 条本队列已提交结果,再按冻结类型过滤。选中的事件先创建唯一的 `(namespace_id, subscription_id, event_id)` 交付,随后推进 cursor_sequence,两者同事务提交。 达到 max_pending 后停止物化。未物化的结果保留在来源日志;任务生产者不锁订阅、也不等待消费者腾出交付窗口。有效确认释放空间后,后续扫描继续追赶。每条交付独立确认,处理顺序可以不同,不靠单个最大 ACK 序号跳过中间事件。 这是有界交付窗口,不是“所有存储都已限制大小”。首版结果日志和已确认交付不自动删除;持久化积压仍占数据库空间。清理、水位推进、容量告警及运维治理需要后续 P6 完成。 ## 5. 租约、确认与恢复 | 状态 | 含义与转换 | | --- | --- | | PENDING | 等待就绪;领取时持久化租约后转 LEASED | | LEASED | 已生成一次发送授权;有效确认转 ACKED,超时后按预算转 PENDING 或 EXPIRED | | ACKED | 已可靠确认;重复确认返回原确认时间,不再次扣减 pending_count,也不重复写确认审计 | | EXPIRED | 已耗尽交付预算,仍保留事件和积压占用;允许带原因的人工追加预算,或接受尚未被替换的最近证明的迟到确认 | 租约固定 30 秒,每次领取增加 lease_epoch 和 attempts,并产生新的 32 字节随机秘密。数据库只保存摘要;确认时使用常量时间摘要比较。当前内部领取按服务端连接 ID 计算容量,单连接容量 1–32。连接 ID 由传输层生成,不能接受客户端自报来绕过容量。 下一次启用状态下的领取,会先回收该订阅的过期交付。未耗尽时使用 `min(2^(attempts-1), 30)` 秒退避;耗尽时转 EXPIRED,不无限自动发送。当前没有独立订阅清扫进程:无人消费或订阅暂停期间,租约状态可能保持 LEASED,直至下一次领取执行恢复。 ACK 表示消费者已可靠保存或处理结果,不授予新业务执行权。当前最后一次秘密和代次即使过期,只要没有被新的交付替换,仍可用于迟到确认;新交付产生后,旧证明统一返回 DELIVERY_LEASE_LOST。确认和重新交付竞争通过订阅锁串行化,只会有一方有效。 服务端不能保证消费者业务恰好执行一次。[独立接收方示例](examples/result-inbox.md) 展示以 event_id 建立唯一 Inbox,在本地事务中保存结果/业务变更,提交后才 ACK。断线和 ACK 响应丢失可能造成重复投递,调用方不得按“收到过一次”就直接丢弃尚未可靠保存的内容。 每次交付授权、首次有效确认、订阅管理和人工重试均记录审计。交付审计包含事件标识、代次、次数、连接和租约截止,不记录交付秘密或结果正文。 ## 6. 管理与人工重试 暂停只停止新的物化和领取,不撤回已经发出的结果。暂停期间仍接受有效 ACK。恢复后继续处理原有积压,事件和交付 ID 不变。 人工重试仅适用于 EXPIRED,必须提供 expected_revision、非空 reason 和 additional_attempts(1–100)。追加后的累计预算最多 1000000;已使用次数、当前代次和原事件身份均不归零。新领取才产生新代次。相同幂等键重报返回原响应,不重复加预算。 当前服务层方法见 [platform/events/service.go](../platform/events/service.go),持久化实现见 [订阅](../internal/storage/mysql/event_subscriptions.go)、[交付](../internal/storage/mysql/event_deliveries.go) 和 [结果日志](../internal/storage/mysql/result_journal.go)。这些是服务端内部模块,独立 SDK 不应直接依赖它们。 ## 7. 迁移与历史边界 [000013](../internal/storage/mysql/migrations/000013_result_subscriptions.up.sql) 新增两个实体,并扩展订阅和交付字段。当前共 35 个实体、289 个直接声明的持久化字段,全部中文注释;新增或调整的 SQL 列提供中文 COMMENT。没有新增外键或级联删除。 升级必须停止旧版本写入节点,应用迁移,再启动支持新结果日志的版本。旧的运行中写入代码不会补写结果日志,不能在启用订阅后继续混跑。迁移沿用显式版本、校验和、dirty 标记和就绪检查,不自动修改历史迁移。 迁移前的 orbit_events 原样保留,不补造无法证明正确的旧结果快照。from_sequence=0 只覆盖迁移启用后的结果日志,不代表系统建立以来全部任务。历史占位订阅缺少可靠队列绑定,升级后保留但禁用,不对外消费;历史交付保留原次数和代次,预算至少等于已用次数。 retained_from_sequence 初始且目前固定为 1。未来实现清理后,如果订阅游标落在保留边界以前,必须返回 EVENT_HISTORY_EXPIRED,不得静默跳到最新结果。请求未来序号也会被拒绝。 ## 8. 下一步 HTTP 创建/查询/分页、暂停/继续、交付与耗尽记录查询、人工重试,以及 orbit.events.v1 WSS、消息 Schema、管理 SDK 和管理示例已实现,见 [接口手册](api/subscriptions.md)。[事件连接 SDK 和自动消费器](sdk/event-consumer.md) 已实现;[持久化 Inbox 去重示例](examples/result-inbox.md) 与消费者进程故障回归已完成。可靠订阅各层均有对应证据,P3其他功能缺口见开发计划。