跳转到内容

存档、重放与派生分支

本页介绍如何保存和加载模拟运行、为长期运行的模拟保存紧凑历史、精确重放一次运行,以及从某个存档点派生分支去尝试另一组输入。

参伍的存档不只是世界数据,还包括命令、事件、结算边界记录、随机抽样、插件语义环境和状态承诺,加载时据此验证状态。插件语义环境指本次运行中每个插件的名称、版本和语义哈希;加载和重放时提供的插件集合必须与之匹配。

持久化路径:运行中的模拟可以保存为快照或检查点日志并用相同插件恢复,可以从重放日志精确重放,也可以派生为平行现实. 查看图表源码.
查看图表源码
flowchart LR
  Run["运行中的模拟"] -->|"snapshot_json()"| Snap["快照"]
  Run -->|"checkpoint_journal_json()"| CJ["检查点 + 后续日志"]
  Run -->|"replay_journal()"| Journal["ReplayJournal"]
  Run -->|"continuation_checkpoint()"| Cont["接续检查点"]
  Run -->|"fork()"| Fork["派生分支:新输入,平行现实"]
  Snap -->|"from_snapshot_json_with_plugins()"| Restored["恢复后的模拟"]
  CJ -->|"from_checkpoint_journal_json_with_plugins()"| Restored
  Cont -->|"CompactedCanwu::from_continuation_checkpoint_verified()"| Continued["接续运行的模拟(紧凑模式)"]
  Journal -->|"replay_from_journal()"| Replayed["重放得到的模拟"]
  Plugins["匹配的插件集合"] -.-> Restored
  Plugins -.-> Continued
  Plugins -.-> Replayed

恢复、接续或重放得到的模拟,checkpoint_hash() 与原运行相同。派生分支从相同状态出发,之后各自发展。

目标 保存 加载 上层应用要做的事
普通存档或服务重启 snapshot_json() Canwu::from_snapshot_json_with_plugins() 保存完整快照,加载时提供相同的插件集合
长期运行的紧凑存储 checkpoint_journal_json() Canwu::from_checkpoint_journal_json_with_plugins() 保存检查点及其后连续不断的日志
不随战役变长、不需要重放的存档 continuation_checkpoint() CompactedCanwu::from_continuation_checkpoint_verified() 保存检查点及其 continuation_hash,见接续检查点
只保存变化部分的增量存档 prepare_paged_checkpoint() Canwu::from_paged_checkpoint_and_journal_with_plugins() 保留每个状态页和连续不断的证据前缀,见分页检查点
审计或精确重建 replay_journal() Canwu::replay_from_journal() 或 replay_from_journal_json() 保存完整日志,并保留匹配的插件集合
向外部系统发送效果 outbox_entries() — 至少投递一次,按 delivery_id 去重,并保存确认状态
从同一时点尝试另一组输入 fork() — 把派生分支当作新的平行现实
use canwu_api::Canwu;
use canwu_reference_world::{ReferenceWorldPlugin, demo_scenario};
let (scenario, _) = demo_scenario()?;
let plugin = ReferenceWorldPlugin;
let canwu = Canwu::new_with_plugins(35, scenario, &[&plugin])?;
let json = canwu.snapshot_json()?;
let restored = Canwu::from_snapshot_json_with_plugins(&json, &[&plugin])?;
assert_eq!(restored.checkpoint_hash(), canwu.checkpoint_hash());

加载时会验证快照的格式、引擎版本和插件描述,任何一项不符都会失败。存档中一旦有插件领域记录,加载时就要传入同样的插件。

starter 示例把这段流程与派生分支、精确重放放在一起运行:

终端窗口
cargo run -p canwu-reference-world --example starter

检查点日志(CheckpointJournal)把一个经过验证的检查点,与其后连续不断的证据打包在一起。它从检查点出发,只携带之后记录的证据,适合长期运行。

精确重放根据日志中的初始场景、输入和已记录证据,重建同一次模拟运行:

let checkpoint_journal = canwu.checkpoint_journal_json()?;
let from_checkpoint =
Canwu::from_checkpoint_journal_json_with_plugins(&checkpoint_journal, &[&plugin])?;
assert_eq!(from_checkpoint.checkpoint_hash(), canwu.checkpoint_hash());
let journal = canwu.replay_journal();
let replayed = Canwu::replay_from_journal(&[&plugin], &journal)?;
assert_eq!(replayed.checkpoint_hash(), canwu.checkpoint_hash());

精确重放的规则:

  • 场景取自日志;replay_from_journal() 只接收插件集合和日志。
  • 运行身份、引擎版本、插件语义环境和已记录证据都必须匹配。
  • 重放直接使用已记录的结果:决策、随机抽样和外部答复都从日志读取,副作用也不会再发送一次。

fork() 把当前时点的模拟复制成一个独立的模拟,适合比较另一组命令、策略或研究假设。派生分支照常通过命令和时间接口推进;输入一旦不同,它就是新的平行现实,与原运行的精确重放是两回事。

派生分支也是不暂停游戏就能存档的办法。Canwu、CompactedCanwu 和每个派生分支都是 Send(不承诺 Sync),派生分支与原运行共用归档提供方,所以可以在模拟线程上调用 fork(),把分支移到工作线程,在那里写出检查点日志、接续检查点或封存段,模拟线程则继续结算原运行;工作线程写出的字节与在原线程上做同样的事完全相同。四条规则保证这样做是安全的:

  • 只用来存档的分支不会读归档提供方。 checkpoint_journal_json、continuation_checkpoint 和 seal_evidence 只读运行自己的状态和证据。如果你还在工作线程上推进、恢复或校验这个分支,它就会和原运行同时读提供方,所以要挂一个能同时服务两者的提供方。
  • 同一对象只有一个答案。 提供方或存储对同一个 (namespace, object_id),不管调用顺序和并发情况如何,都必须给每个运行同样的答案;忙的时候(比如另一个线程正在写入)要阻塞,而不是返回 None 或错误,否则边界会把它当成对象缺失或不可读。运行持有的存储以 Arc<dyn … + Send + Sync> 传入,请把它的映射放在 Mutex 或 RwLock 后面。
  • 保留活跃分支还在读的东西。 删除归档提交淘汰的查找页,就放弃了运行中所有读取它们的更早时点,包括头部早于这次提交的分支,见归档终态记录。分支还在工作线程上活跃时,不要删除它仍会读的页或参考包归档对象。
  • 分支的封存只属于分支。 封存分支不碰原运行的证据和封存历史,分支返回的封存段属于分支自己的存档,不属于原运行的段链。

fork() 本身是常数时间:不复制状态,也不复制日志。两边都还持有共享日志时,先追加的一方要复制日志(完整历史的运行在 65,536 条记录时约 53 毫秒),所以一个在原运行的下一个边界时仍然活着的存档分支,会把这次复制转移到原运行的那个边界上,而在以前的版本里是分支自己承担的;在意这一点时,请在下一个边界之前完成或丢弃存档分支,或者先封存,因为封存过的运行只复制上次封存之后的尾部。

需要送达外部系统的边界输出会出现在 outbox_entries() 中。每个 OutboxEntry 都有稳定的 delivery_id,由运行、结算边界、事件和输出序号派生而来。

  1. 在自己的存储中保存每个 delivery_id,以及对应的投递状态和确认状态。
  2. 按“至少一次”投递,超时或重启后可以安全重试。
  3. 接收方按 delivery_id 去重,因为同一次网络调用可能发生不止一次。
  4. 精确重放会生成相同的 delivery_id,投递仍由上层应用负责。

在紧凑模式(CompactedCanwu)下,seal_evidence() 返回封存后的 EvidenceJournalSegment。请把这个日志段与投递、确认状态一起保存。封存或压缩证据,并不表示效果已经投递或确认。封存不再等待输入队列清空:它把事件、命令和命令尝试封存到准入游标为止,其他日志封存到末尾,并把仍在进行中的工作可能还要读取的证据(待处理输入及其原因、需要保留载荷的依赖,以及尚未准入的尾部)留在内存里,所以忙碌的运行想封存多频繁都可以。封存之后,ingress_log 只保存保留的尾部,但 pending_ingress() 仍会列出每条排队中的记录。只有当某个插件声明依赖的证据运行已经不再持有时,封存才以 ArchiveNotReady 失败,并且什么也不改变。

接续检查点(ContinuationCheckpoint)是一种不带证据历史就能继续运行的存档。它包含当前状态、仍在进行中的工作可能还要读取的证据、恢复时无法脱离历史重建的派生索引(操作定址抽样明细中在它的时刻尚未退役的抽样、让每个带请求标识的请求保持幂等的请求索引、已封存证据的时间索引,以及边界账),以及可续写形式的日志承诺。它不包含任何仍在进行中的工作读不到的边界、事件、命令、输入或抽样记录,所以它的大小取决于当前状态以及带请求标识的请求和操作定址抽样的数量,与战役的长度无关:在记录的 continuation-profile 运行中,第 14 个月时它是 2.2 到 5.7 MB,完整存档则是 150 到 160 MB;写出只需完整存档 4% 到 9% 的时间,恢复只需 1% 到 3%。

let (checkpoint, continuation_hash) = canwu.continuation_checkpoint()?;
// 两者都要保存;哈希是信任锚点
let continued = CompactedCanwu::from_continuation_checkpoint_verified(
checkpoint,
&continuation_hash,
&[&plugin],
)?;
assert_eq!(continued.checkpoint_hash(), canwu.checkpoint_hash());
  • 写出。 Canwu::continuation_checkpoint() 和 CompactedCanwu::continuation_checkpoint() 返回检查点及其 continuation_hash。写出不改动模拟运行。在同一个切点上,无论运行从未封存、在任何切点封存过,还是本身就是从接续检查点恢复的,检查点都逐字节相同。封存会失败的情况下它同样以 ArchiveNotReady 失败。
  • 恢复。 CompactedCanwu::from_continuation_checkpoint_verified(checkpoint, expected_hash, plugins) 先把重新计算的哈希与你保存的那份比较,再检查格式、完整性、结构、状态、操作定址抽样(值正确,并且没有一个已经退役)、身份(每个状态根和检查点哈希),以及携带的证据是否恰好是恢复后的状态所需要的,然后才重新装载插件。_with_plugins 形式和 JSON 形式跳过与保存哈希的比较,只能发现损坏,不能发现篡改;凡不是自己在同一进程里写出的检查点,都请用带校验的形式。恢复得到的是 CompactedCanwu,永远不是 Canwu。
  • 接续后的运行能做什么。 结算边界、准入和撤回输入、处理命令和决策、封存、派生分支、再写接续检查点,以及回答所有不需要历史的上层应用读取,结果与未中断的运行相同。它还能用 pending_ingress 列出仍在排队的输入,包括存档之前排入的输入(仅限受信任的上层应用:在向玩家展示任何内容之前,先挑出自己的条目,例如没有 cause 的条目)。CompactedCanwu::continuation_base() 报告它的起点切点。
  • 不能做什么。 不能写出完整快照、重放日志、检查点日志或分页检查点(ErrorCode::HistoryUnavailable);不能读取起点之前的已归档日志段;通过保留证据的读取也看不到起点之前的边界、事件和轨迹。需要精确重放或审计的上层应用,请同时保留完整存档。
  • 信任。 接续检查点和快照一样包含真值,绝不要交给玩家、智能体、观察者或远程客户端。它自带的 continuation_hash 字段只能发现损坏,发现不了篡改:携带的证据、待处理集合和已结算切点只由你保存的那份哈希覆盖。
  • 上层应用辅助函数。 扩展中为上层应用排队或读取的每个辅助函数都接受任何 CanwuRun,即 Canwu 和 CompactedCanwu 都实现的 trait,所以带归档的运行或接续后的运行与完整历史的运行用同样的调用驱动。会重放历史的模块恢复包装函数(技术、生产、society、历史研究)没有接续形式。不过,技术状态仍可以在接续后的运行上检查:调用 validate_technology_runtime_on,它接受两种运行句柄,见技术系统。

需要让某条规则的证据在封存之后仍可读取的插件,要在当前记录中把它声明为需要保留载荷;插件不得这样声明初始场景中的记录版本(它不用声明也始终可读,引擎会在封存时拒绝这种声明)。

只有当快照体积或热历史增长真正成为问题时,才需要分页检查点、StatePageProvider、决策历史归档和插件归档参与者。到那时,上层应用必须:

  • 按内容标识保存每个状态页,并原样返回相同字节;缺页必须报错,不能当作空数据;
  • 保留每个检查点所需的连续日志前缀和归档保留根;
  • 保留资源、生产、财政和参考包归档的查找页:恢复、精确重放或派生分支沿途经过的每个归档头都需要它们(见归档终态记录);
  • 为每个插件恢复相同的归档契约和语义哈希;
  • 删除冷数据前,先从已提交的根做可达性检查。

分页检查点是当前状态检查点按内容寻址的形式:它以状态页的形式保存在 StatePageProvider 中,之后每个新检查点只增加发生变化的页。从 0.13.2 起,可以通过对外 API 写出和恢复分页检查点:

  • 写出。 Canwu::prepare_paged_checkpoint(source, provider) 把当前状态整理成一个 PreparedPagedSimulationCheckpoint。其中的增量不包含提供方已经持有的页;source 是上一个检查点(如果有)。store_and_verify 负责保存这份增量。准备检查点不会改动模拟运行:状态修订号、检查点哈希和所有日志都保持不变。
  • 恢复。 Canwu::from_paged_checkpoint_and_journal(checkpoint, provider, segments, budget) 和 from_paged_checkpoint_and_journal_with_plugins 根据提供方和证据前缀重建模拟运行。证据前缀是从运行开始到该检查点为止、连续不断的日志段,可以在运行过程中用 journal_segment_since 逐段收集。每个日志段至少要包含一条新记录,所以起点与终点相同的日志段要跳过。恢复时执行的验证与加载快照相同。提供方只需持有恢复时实际读取的页;读取的页缺失时会以 StatePageUnavailable 失败,不会当作空数据。对已归档的决策历史,恢复只读取热决策状态所依赖的页;其他已归档历史会报告 Unresolved,直到通过提供相同页的 DecisionArchiveProvider 调用 decision_history_location_with_provider 查到它。
  • 紧凑模式。 CompactedCanwu 提供同样的调用。它的证据前缀是你已封存的日志段(按封存先后排列),再加上用 journal_segment_since 从最后一段的终点取得的日志段;如果封存之后没有记录任何内容,就跳过这最后一段。紧凑模式恢复之后,证据前缀会改变,见重启紧凑模式的模拟运行。

检查点本身不携带证据记录。除了紧凑模式的接续信息(例如每次封存对应的一个归档日志段头),它的大小取决于当前状态,而不是运行持续了多久;每份增量只包含发生变化的页。准备检查点时仍可能读取保留的证据,恢复时也仍会验证完整的证据前缀。领域记录页是二进制页(raw-canonical-v2):超过 1 MiB 的记录按内容寻址切成 1 MiB 的记录值分块分页保存,没有变化的分块不会存两次,最大 512 MiB(MAX_PAGED_RECORD_BYTES)的记录都能分页;更长的记录会让 prepare_paged_checkpoint 以 ValueOutOfRange 失败,这不是页损坏。知识账本也按同样的方式分页,分成两张按内容寻址的映射(记录正文按 ID,持有人账本按持有人,各占一个叶页,所以没有变化的正文或持有人仍用原来的页);紧凑检查点正文和热决策状态是 1 MiB 的分块页,所以分页检查点没有哪一部分受限于单页:有 4,000 个报告持有人、账本 9.8 MB 的世界存成约 16,000 页,准备约 90 毫秒,恢复约 250 毫秒,一天的增量约 2,300 页。

每次分页恢复的最后一个参数都是恢复预算。PagedRestoreBudget { max_assembled_bytes } 限制恢复从页中组装出来的字节数:每条记录值、知识记录正文、持有人账本、紧凑检查点正文和热决策状态都先计入预算再分配内存,超出预算的值在读取它的任何分块之前就以 RestoreBudgetExceeded 失败,什么也不恢复;描述记录声明的数目超过预算时,在读取任何页之前就拒绝。描述记录在 PagedSimulationCheckpoint::assembled_bytes 里写明诚实恢复时的精确数目,与页实际组装出来的数目不一致的描述记录会被拒绝,所以恢复自己写出的检查点时,传 PagedRestoreBudget::new(checkpoint.assembled_bytes) 即可。PagedRestoreBudget::DEFAULT(2 GiB)是你自己更大的检查点的上限。预算限制的是组装出来的字节数,不是堆内存:恢复同时持有组装出来的值、由它们解码出的状态和校验时的副本,解码后的 JSON 可能是文本的许多倍,所以 2 GiB 预算之内的恶意页仍可能让峰值内存达到几十 GiB。来源不可信的页,请传一个接近预期大小的预算(你写出的检查点的 assembled_bytes 加上余量),并把根页 ID 与自己记录过的对照认证;DEFAULT 不是这种场合的界限。你自己封装分页恢复的入口(例如页存储适配器)也要接收并传入这个预算。

需要注意:

  • 提供方要一直保留之前检查点的页。从分页检查点恢复的模拟运行再准备检查点时,增量同样不包含它从未加载过的已归档决策页,所以它的提供方必须已经持有恢复所用检查点的全部页。store_and_verify 只检查新增的页和根;要检查一个检查点的完整页集合,请使用 StatePageRetentionLedger::prepare 和 verify。
  • 检查点列有插件时,紧凑模式的恢复必须使用 _with_plugins 形式。
  • 分页根不是状态标识,因为它还绑定了哪些证据已经封存。凡是从已封存日志段恢复,无论通过 Canwu 还是 CompactedCanwu,检查点哈希都与原运行相同,分页根却不同,之后再封存也不会变回原来的分页根。从未封存过证据的运行,恢复后的分页根与原来相同。判断状态是否相同,请比较检查点哈希,而不是分页根。

紧凑模式的恢复,无论是从分页检查点恢复,还是通过 CompactedCanwu::from_checkpoint_and_journal 及其 _with_plugins 形式恢复,都会把你传入的整个证据前缀作为保留证据。这会改变之后恢复所需的日志段:

  1. 恢复后的模拟运行再次封存之前,它的检查点使用你恢复时传入的证据前缀,再加上用 journal_segment_since 从该前缀终点取得的日志段。
  2. 它下一次调用 seal_evidence() 时,会返回一个从运行开始处起算的日志段,取代之前封存的所有日志段。
  3. 从此以后,证据前缀以这个新日志段开头,再加上用 journal_segment_since 从它的终点取得的日志段。
  4. 之前的日志段只需为那次封存之前的检查点保留。

如果证据前缀仍把之前的日志段放在新日志段前面,前缀就不连续,恢复会失败。

格式编号、分页存储契约和归档不变量见仓库中的版本与持久化契约。

参伍 0.23.0 是次版本,不改变任何引擎格式编号。技术插件和财政插件的语义哈希都改变了,所以注册了其中任一插件的运行,即使引擎版本相同,也不能加载 0.22.1 的存档。每条技术记录现在还会列出它所引用的版本所依赖的正文,所以同样的操作会写出更大的记录,接近 16 KiB 技术载荷上限的记录现在可能超限而被拒绝。新操作如果精确引用了已被取代、且没有任何当前技术记录依赖的版本,现在也会被拒绝。这样,从接续检查点恢复的运行或封存过的运行,之后处理技术操作与从未中断的运行完全一致;可以用 validate_technology_runtime_on 检查这样的运行(见技术系统)。财政插件的版本升为 0.6.0-experimental,新增一条命令,让几个机构的批次在同一个边界准入、在下一个边界全部结算(见批次组),一条证据记录现在也可以服务几个范围(见汇总执行证据)。运行现在可以在继续进行的同时把席位交给继任者:生效的绑定随每种存档保存,计入检查点哈希,但不计入权威状态哈希,所以恢复接续检查点时,要用 from_continuation_checkpoint_verified 并传入保存时得到的接续哈希。从不改绑席位的运行不会写出任何新增的席位改绑字段,它的存档和各项哈希与没有这项功能时相同。用结构体字面量构造 FiscalExecutionEvidence、BoundaryRecord、BoundaryReceipt 或 SimulationSnapshot 的代码要补上新字段,对 IngressPayload 或 BoundaryIngressKind 做穷尽匹配的代码要补上 SeatRebinding 分支。与存档和代码有关的全部变化见 docs/versioning.md 的 0.23.0 条目。

上一个版本参伍 0.22.1 是补丁版本,没有改变任何引擎格式、对外 API 或插件语义哈希。读取一页知识记录的开销现在只取决于这一页,不再随持有人的全部历史增长;返回的内容与 0.22.0 完全相同:同样的记录、游标和读取切面,所以通信和法律记录里保存的读取切面也保持原值。只有引擎版本变了:与任何版本变化一样,每个检查点哈希都会记录它,所以 0.22.1 引擎不能加载 0.22.0 的存档。全部变化见 docs/versioning.md 的 0.22.1 条目。

0.22.1 之前的次版本参伍 0.22.0 没有改变任何引擎格式:快照 9、承诺 8、检查点/证据日志 6、分页检查点 7、接续检查点 4,以及状态修订、准入游标和精确重放格式 3 都不变,各插件的语义哈希也不变。它在运行句柄上新增受信任的上层应用读取:任意持有人的当前知识记录,以及读取运行当前资源根的读取器。自己实现 CanwuRun 的类型需要补上新增的四个方法(knowledge_heads、knowledge_current_records、domain_record_shared、domain_records_of_kind)。已读资源根只存在于上层应用的内存中,从不保存,所以恢复之后要重新读取一遍。与任何版本变化一样,每个检查点哈希都会记录新的引擎版本,所以 0.22.0 引擎不能加载 0.21.0 的存档。与存档和代码有关的全部变化见 docs/versioning.md 的 0.22.0 条目。

0.22.0 之前的次版本参伍 0.21.0 只改变了一种引擎格式:承诺格式 8 通过 canwu.commitment.decisions.v4 承诺决策状态,其中已承诺票据的选项带有票据选项偏置(DecisionOption::bias,为零时省略),所以带决策状态的运行的每个检查点哈希和状态承诺都与 0.20.0 不同;决策归档 blob 和凭证的格式是 2。快照 9、检查点/证据日志 6、分页检查点 7、接续检查点 4,以及状态修订、准入游标和精确重放格式 3 不变。它还改变了移动插件(在持预订)和军事插件(载荷 schema 版本 2)的语义哈希;财政归档不改变任何格式,只是接续恢复不再算作封存(见财政)。与存档和代码有关的全部变化见 docs/versioning.md 的 0.21.0 条目。

再往前一个次版本参伍 0.20.0 改变了两种引擎格式:承诺格式 7 通过 canwu.commitment.decisions.v3 把决策的准入批次(最近一个边界开启的票据)纳入决策根,所以带决策状态的运行的每个检查点哈希和状态承诺都与 0.19.0 不同;接续检查点格式 4 只携带在它的时刻尚未退役的操作定址抽样,恢复时拒绝列有已退役抽样的检查点(见定址抽样退役期)。快照 9、检查点/证据日志 6、分页检查点 7,以及状态修订、准入游标和精确重放格式 3 不变。它还改变了通信、移动、资源、生产、技术和军事插件的语义哈希,以及资源状态格式(6)和两类资源桶的格式(2)。与存档和代码有关的全部变化见 docs/versioning.md 的 0.20.0 条目。

再往前一个次版本参伍 0.19.0 没有改变引擎的持久化格式(快照 9、承诺 6、检查点/证据日志 6、分页检查点 7、接续检查点 3;状态修订、准入游标和精确重放格式仍是 3),新增受信任的上层应用读取 pending_ingress,并改变了移动插件、资源插件和资源状态格式(5)。与存档和代码有关的变化如下:

  • 载入后列出待处理输入。 Canwu、CompactedCanwu 和 CanwuRun 上的 pending_ingress() 按队列顺序列出每条排队中的输入记录,包括封存或接续检查点恢复时留在保留日志之外的记录,而 ingress_log 只保存保留的尾部。这是受信任的上层应用读取:它不加筛选地包含每个来源的载荷(上层应用的命令、决策输入、其他插件的内部数据包、边界生成的工作),所以在向玩家展示任何内容之前,请先挑出自己的条目,例如没有 cause 的条目,或带有你自己请求 ID 的条目。它不改变任何格式、哈希或重放输入。
  • 让路段滞留。 移动操作 DelayLeg 让当前路段(已出发的推迟到达,未出发的推迟出发)滞留到一个不早于其排定时刻的绝对时刻,见路段计时。
  • 移动证据必须是当前版本。 事故的证据和移动命令的 delivery_attempt 在结算时必须是各自记录的当前版本,所以封存过的运行与它的重放判定一致。移动命令结算之前,它的投递尝试记录如果被修订,这条命令就会被拒绝;请用新版本重新提交。
  • 进入时的检查。 移动运行时根记录(来自 MovementState::into_initial_record 或存档)中,如果某个已出发路段的到达到期时间不等于出发时间加计划时长,又没有滞留可以解释,载入时就会被拒绝。
  • 资源状态格式 5。 转移带有它的运输关联历史(link_history,记录它推进时经过的每个路段和交接,归档时也随转移一并保存),未结束的转移只在这里保存运输证据:它的 exact_evidence 在终态处置之前为空。租约的资格信封(EligibilityEnvelopeV1)可以带 operation_binding。没有运输关联的转移和未绑定的信封编码不变,归档 blob 格式仍是 1。转移的运输关联历史如果不可能由它的推进记录下来,恢复时会被拒绝。
  • 资源记账。 验收按实际验收的数量为需求记履约,而不是按整条分配腿,所以转移部分损耗会让需求重新空出相应的数量;分配时把在途的托管量计为已动用;已终止需求的记录会留在活动根里,直到动用它们的转移结束;Lose 把剩余托管量加到 lost 上。
  • 需要修改的代码。
    • 移动。 MovementOperation 新增 DelayLeg;MovementOrderRecordV1 和 MovementReportDetailV1 新增 hold(MovementLegHoldV1,为空时省略,所以没有滞留的运行时或报告编码不变)。
    • 运行句柄。 CanwuRun 新增必需方法 pending_ingress;你自己实现这个 trait 的类型要加上它。
    • 资源。 ResourceOperationRequestV1 新增 RecordTransferLoss 和 RescopeAccount,ResourceOperationKind 新增 RescopeAccount,ResourceTransfer 新增 link_history,EligibilityEnvelopeV1 新增 operation_binding,ResourceCreditSourceV1::ExternalInflow 改为保存记录版本(DomainRecordVersionRef)。带请求标识的外部流入 Credit 需要一份恰好绑定这笔入账的一步式完成租约(EligibilityEnvelopeV1::bound_to),地点范围只能通过 enqueue_resource_account_rescope 更改,见完成租约。
  • 插件身份。 移动插件的语义哈希及其 movement_report schema 哈希改变,资源插件的语义哈希也改变;其他插件不变。

和任何一次版本变化一样,每个检查点哈希中的引擎版本都会改变。从 crate 版本取得插件版本的第一方插件,也会报告新的插件版本。精确的引擎版本检查会拒绝 0.18.0 的存档;需要读取时,请保留 0.18.0 引擎。

上一个次版本参伍 0.18.0 没有改变平面存档的格式(快照 9、承诺 6、检查点/证据日志 6;状态修订、准入游标和精确重放格式仍是 3),改变的是分页检查点(格式 7)、接续检查点(格式 3)和若干扩展的格式。下面的数字来自仓库中的基准测试,在一台开发机上记录(见 docs/versioning.md)。与存档和代码有关的变化如下:

  • 请求结果只保留一段时间。 带请求标识的命令请求的精确结果和排队回执,按模拟运行的请求结果保留期保留(RunConfiguration::with_request_outcome_retention,默认 30 个模拟日,最长 366 天,在创建运行时定下):保留期内完全相同的重试拿回原来的结果;超过之后请求退役,它的 ID 在整个运行期内仍算已使用,再用这个 ID 提交任何请求,不论是否完全相同的重试,都以 RequestOutcomeRetired 被拒绝,不留下尝试、修订号或事件。上层应用凡是把 IdempotencyConflict 当作“这个 ID 已被占用,换一个”的分支,都要同样处理 RequestOutcomeRetired。规则只看模拟时间,所以现实时间的暂停不会让任何结果退役,模拟速度越快、按现实时间算退役得越早,而未封存的运行、封存过的运行、从接续检查点恢复的运行和精确重放回答一致,写出的接续检查点逐字节相同。
  • 如何选择保留期和分配请求 ID。 请按最高的模拟速度和客户端最长的重试间隔选择保留期;可能离线更久的客户端,由上层应用用自己收到过的结果回答。请连续分配请求 ID(用运行状态能恢复的计数器,或者批次所依据的修订号加偏移量):这样退役的 ID 会合并成寥寥几个区间,而按键哈希出来的 ID 每个要在整个运行期内占约 59 字节。恢复之后,下一个可用的 ID 是检查点中 retired_commands、commands、queued_commands、decision_commands、未准入的尝试和待处理输入里最大 ID 加一。在世界规模下,原来每个模拟月因请求索引增长约 3.8 MB 的接续检查点,现在从第 5 个月到第 24 个月总共只增长 0.85 MB。
  • 接续检查点格式 3。 RequestIndex 新增 retired_commands,即已退役请求 ID 的规范化区间;commands 只保存保留期内的结果,queued_commands 不再保存已退役请求的回执。恢复时会拒绝不规范的区间、落在区间内的已保存结果或回执、超出保留期的结果,以及回执缺失或 ID 已退役的待处理命令请求。
  • 进入时的检查。 初始记录声明了任何封存都无法接受的证据(需要保留载荷或身份依赖指向初始场景版本、记录自己的版本,或引用畸形)的场景或存档,在插件注册时(InvalidDomainRecord)和存档恢复时(InvalidSnapshot)就被拒绝,而不是开始一场永远无法封存、也无法写接续检查点的运行。enqueue_command 拒绝请求 ID 0。插件描述可以带一份发布预留;运行只能用声明了相同预留的插件恢复和重放。
  • 需要修改的代码。
    • 财政。 prepare_fiscal_archive(&run, book_limit, &store) 取代 FiscalState::prepare_fiscal_archive,enqueue_fiscal_archive 去掉了 sealed_cut 参数,封存切点改由运行本身提供(sealed_evidence_cursor,CanwuRun 新增的必需方法);会封存的上层应用在每次存档时先封存。fiscal_report schema 升到版本 6(紧凑的分段载荷),插件版本为 0.5.0-experimental。
    • 资源。 RESOURCE_STATE_FORMAT_VERSION 升到 4:账户、动用授权、资源定义、单位版本和保管人索引持久化在常驻终态桶旁边的活动桶(resource:live:{bucket})里,两类桶共用记录种类 canwu.resource bucket(ResourceBucketRecord 取代 ResourceHotTerminalBucketRecord),常驻终态桶每桶约两条记录。ResourceState::into_record 拒绝带期初账户的根,请改用 into_records。max_mutations_per_boundary 改名为 max_batch_items_per_boundary 并真正生效;ResourceConsumption 新增 fulfillment;上层应用的 Expire 在入队时被拒绝;已结束的租约离开资源根。
    • 路由。 TraversalModel 新增 Recurring,RoutingError 新增 InvalidConditions,RoutePlan 新增 edge_conditions_digest,ROUTING_ALGORITHM_VERSION 为 canwu-routing.v2,所以保存下来的快照摘要和计划摘要与 0.17.0 不同。
    • society。 SocietyState 新增 relation_vocabulary;插件描述去掉了三个结算系统、新增关系接收系统,所以 society 的每个语义哈希都改变;一次结算只记一条“Settled society”记录变更。
    • 法律与信息。 LegalDefinition 新增 caseload;LEGAL_SHARD_PAYLOAD_FORMAT_VERSION 升到 2;LegalRuntime 序列化时不含 plan;max_provider_packets 默认 256。信息扩展的 delegated_authority 记录改为每个命令授权一本带版本的账,delegated_authority_reference 只接受授权代码。
    • 参考整合包。 ForceSupplyStateV1 和 EconomyReferenceStateV1 升到格式 2,新增归档凭证链;prepare_economy_archive 接收运行的 force-supply 根记录;finalize 辅助函数返回 Option<IngressReceipt>。
    • 生产。 ProductionState 新增 scope、limits 和 incident_history;需要多个根的上层应用为每个范围在 canwu.production:runtime:scope:{scope} 下安装一条;ProductionLimitsV1 用 max_batch_items 和 max_operations_per_boundary 取代 max_mutations_per_boundary,去掉 max_incident_receipts;PRODUCTION_ARCHIVE_BLOB_FORMAT_VERSION 升到 3(残留记录、三种新归档键、已归档执行上的 decision_receipts、凭证上的 duplicate_settlements、批次、提交和凭证上的 scope,以及 canwu.production.archive-source-root.v2 域);每个数据包都带 scope;范围根的租约和产出键以 {scope}/ 开头,主根的键不含 /。
  • 插件身份。 society、法律、信息、通信、财政(0.5.0-experimental)、资源、生产和两个参考经济插件的语义哈希改变;军事、移动、技术和文化插件不变。
  • 分页检查点格式 7。 每个分页恢复入口(Canwu 和 CompactedCanwu 上的 from_paged_checkpoint_and_journal 及其 _with_plugins 形式,以及 PersistentDomainRecordStore::from_state_pages)新增最后一个参数恢复预算(PagedRestoreBudget),ErrorCode::RestoreBudgetExceeded 是新错误码;你自己调用分页恢复的页存储适配器或存档封装要加上这个参数。描述记录新增 assembled_bytes;紧凑检查点正文从单个封装页改为分块页,知识账本按记录正文和持有人账本分页,决策清单的热状态也分块,所以有几千个报告持有人的世界又能存成分页检查点了。请用 0.18.0 重新写出分页检查点;页收集器照旧通过 StatePageRetentionLedger 读取新的封装。
  • 封存快了十倍,字节不变。 seal_evidence 对每条封存的记录只编码和哈希一次,只为运行仍保留的依赖生成凭证;约 900,000 条索引条目的封存从 30 到 59 秒降到 3.3 秒,每个日志段、索引、凭证和哈希都逐字节相同。
  • 派生分支和恢复更便宜,字节不变。 运行时现在用持久化的共享结构保存当前状态、调度器和保留的日志:fork() 是常数时间,不复制任何日志;恢复一份大的未封存快照快了约三分之一;每份快照、检查点、日志、哈希和每个拒绝结果都没有变化。派生之后,两边都还持有共享日志时第一次追加的一方要复制日志,所以一个在原运行的下一个边界时仍然活着的存档分支,会把这次复制(65,536 条记录时约 53 毫秒)转移到原运行上;封存过的运行只复制上次封存之后的尾部。见平行现实。

和任何一次版本变化一样,每个检查点哈希中的引擎版本都会改变。从 crate 版本取得插件版本的第一方插件,也会报告新的插件版本。精确的引擎版本检查会拒绝 0.17.0 的存档;需要读取时,请保留 0.17.0 引擎。

再往前一个次版本参伍 0.17.0 改变了每种持久化格式(快照、承诺、检查点/证据日志、分页检查点封装、接续检查点;状态修订、准入游标和精确重放格式仍是 3),原因只有一个:知识账本现在每个记录正文只存一份、每个持有人各一个持有人条目,schema 还可以在发布时清退被取代的条目(留存)。快照格式 9 把账本编码为正文和持有人条目,每个持有人带它的下一个本地记录号;承诺格式 6 按块计算账本的哈希(每个条目一个叶哈希,每 256 个本地记录号一块,每个持有人一个根),所以条目可以离开;检查点/证据日志格式 6 把每次发布记为它的正文和每个持有人的条目,每条指令一个列出全部持有人的 KnowledgePublished 事件;分页检查点封装(格式 6)和接续检查点(格式 2)内嵌新的快照。哈希算法没有变化,但每个检查点哈希、边界哈希和状态承诺都与 0.16.0 不同。与存档和代码有关的变化如下:

  • 存档只保存当前的知识。 在 RetireWhenSuperseded 下,持有人每条线只保留一个条目,所以有几千个报告持有人的世界,它的接续检查点在模拟的年月中保持平稳,不再随每次发布增长。0.17.0 的分页检查点仍把整本知识账本放在一个 4 MiB 的封装页里,几千个报告持有人的世界放不下;从 0.18.0 起账本已分页(见分页检查点),证据前缀长到恢复时无法验证的运行,仍以接续检查点为存档。
  • 需要修改的代码。
    • 发布方。 发布知识的插件把原来的指令包一层:PublishKnowledge { visibility, producer_correlation, supersession: KnowledgeSupersession::Explicit, groups: vec![KnowledgePublicationGroup { holders: vec![holder], records }], summary }。原来按 KnowledgeLimitsV1::batches_per_system_boundary(已移除)控制节奏的插件,改读 view.knowledge_publication_budget(),只发布 take 允许的部分。每个 PluginKnowledgeSchema 都要写明 retention。
    • 读事件和边界记录的代码。 KnowledgePublished 带 holders、record_count 和 entry_count,受众是 EventAudience::KnowledgeHolders;BoundaryKnowledgeChange 带 records(正文)和 holders(条目)。
    • 读账本的代码。 HolderLedgers::record、records、records_after、record_count、last_record_id 和 remove 已移除,由 insert_publication、remove_entry、body 和 HolderLedger::entry 取代,KnowledgeQueryResult 新增 retention。
    • 财政。 FiscalObserverBinding 新增 granularity 和 cadence(都有默认值),根记录去掉了 queue_pages,FiscalState 新增 report_schedule,报告日程记录是新增的记录。
  • 插件身份。 财政插件升到 0.4.0-experimental,语义哈希改变,唤醒节奏(with_report_cadence)、报告份额和留存策略这几个选项都是它身份的一部分。每个注册了知识 schema 的插件都把 schema 的留存策略记入插件描述。
  • 进入时的检查。 知识账本里有没人持有的正文、指向不存在正文的条目、乱序的本地记录号,或在持有人内解析不了的关联的快照会被拒绝;恢复时引擎从日志重建账本,和存档里的账本对不上也会被拒绝。
  • 接续闸门。 canwu-api 的 Cargo feature continuation-gate 是发布前的测试,应用绝不要开启:它让每次修改同时在运行本身和一个从接续检查点恢复的封存影子上执行,有任何分歧就 panic。

和任何一次版本变化一样,每个检查点哈希中的引擎版本都会改变。从 crate 版本取得插件版本的第一方插件,也会报告新的插件版本。精确的引擎版本检查会拒绝 0.16.0 的存档;需要读取时,请保留 0.16.0 引擎。

参伍 0.16.0(0.17.0 之前的次版本)改变了三种格式,新增一种。承诺格式 5 增量计算知识账本的哈希,并把边界的知识变化的每个字段都纳入边界哈希;检查点/证据日志格式 5 去掉了操作定址抽样的预留项,因为操作定址抽样明细现在保存每个抽过的地址;分页检查点封装格式 5 把最大 512 MiB 的记录按 1 MiB 分块分页;接续检查点是新增的。哈希算法没有变化,但每个检查点哈希、边界哈希和领域记录根都与 0.15.0 不同,authoritative_state_hash 现在是承诺格式的状态哈希,封存过、未封存和恢复后的运行得到相同的值。与存档和代码有关的变化如下:

  • 存档不再随进行中的工作增长。 有待处理输入时也能封存;接续检查点用几 MB 就能保存一场长战役。引用已封存证据的首次操作定址随机抽样在封存过和未封存的运行中都被接受,引用已被取代的记录版本的首次抽样在任何运行中都被拒绝。
  • 需要修改的代码。 DomainRecordChange::previous 和 current 的类型是 Arc<DomainRecord>。KnowledgeSnapshot::records 的类型是 HolderLedgers,它的迭代器只能正向遍历(.rev() 和 next_back 不能编译;用 last_key_value 读最新记录)。ErrorCode 标记为 non-exhaustive,并新增 HistoryUnavailable。SocietyIngressQueue 用 queued 和 batches 取代了 entries。ProductionState::prepare_production_archive 多了一个归档存储参数,prepare_force_archive 和 prepare_economy_archive 也是。财政 API 围绕记录、批次和按记录的修订号重建:FiscalActionBatch 取代 FiscalActionRequest,enqueue_execution_receipts 取代 enqueue_execution_receipt,FiscalState::into_records 取代 into_record,FiscalAction 新增六个变体。命令处理器不能再安排一条会排入插件输入的延时指令(InvalidPayload);请直接给 EnqueuePluginIngress 设定延时。
  • 插件身份。 society 插件(它的语义哈希现在还取决于转移结算模式)、财政、资源、生产、法律、信息和军事插件,以及两个参考经济插件的语义哈希改变。
  • 归档。 生产归档和参考包归档获得了资源归档的查找索引,财政归档是新增的;请像对资源归档一样,保留每种归档的查找页。0.15.0 写出的生产记录和归档对象、财政状态、society 队列记录和参考包归档目录无法解码,不过 0.16.0 的模拟运行不可能读到它们。
  • 资源根。 RESOURCE_STATE_FORMAT_VERSION 升到 3(0.18.0 为 4,新增活动桶,记录种类为 bucket;0.19.0 为 5;见上文):资源根持久化为活动根(resource:runtime:v1,载荷 ResourceLiveRootV1,用一本账计数常驻终态记录)和每个常驻终态桶一条记录(新记录种类 canwu.resource hot-terminal,resource:hot-terminal:{bucket},格式 1),字节上限按活动根加全部条目计算。归档 blob、保留句柄和目录引用 v2 的归档来源根,所以它们的 id 与 0.15.0 不同,形状不变。原来从 ResourceRuntimeRecord::Payload 读常驻终态记录的代码,改用 resource_root_in_view、ResourceRootReader 或 whole_resource_root_in_view,并声明 resource_hot_terminal_state_key();含终态记录的根用 ResourceState::into_records 取代 into_record;force-supply 的恢复函数多了一个归档提供方参数。见资源与生产。
  • 拒绝不再卡住模拟运行。 各扩展中,因可预期的原因无法结算的数据包都是记录在案的拒绝、带事件的跳过或延后结算:放不进资源记录或操作键已被占用的资源数据包(跳过的数据包)、无法解码或未通过检查的生产数据包、财政凭证、语境变更和归档数据包、超出预算或上层应用版本过期的法律数据包,以及相互冲突的信息数据包。任何预算或拒绝都不会再让边界失败。
  • 进入时的检查。 不是有效状态精确拆分的财政记录集合、头与批次不一致的 society 队列、产出超过 64 项的工艺,以及会排入插件输入的已调度工作,都在创建或恢复模拟运行时被拒绝。插件不得把初始场景中的记录版本声明为需要保留载荷;这样声明的运行无法封存。

和任何一次版本变化一样,每个检查点哈希中的引擎版本都会改变。从 crate 版本取得插件版本的第一方插件,也会报告新的插件版本。精确的引擎版本检查会拒绝 0.15.0 的存档;需要读取时,请保留 0.15.0 引擎。

更早的版本(0.15.0、0.14.0、0.13.x)见仓库中的版本与持久化契约;它们同样遵循存档只能由写出它的引擎版本加载这条规则。

  • 需要保存存档的应用,请固定到确切的发布版本或 Git 修订。
  • 插件语义哈希变化意味着行为变化。任何版本发布,即使是补丁版本(例如 0.13.0 → 0.13.1),都可能同时改变多个扩展的语义哈希,使用这些扩展的旧存档随之无法加载。
  • 参伍不迁移旧存档。要跨引擎版本保留历史,请运行自己的导出与导入流程,或保留写出这些数据的旧引擎。
  • 新版本会增加枚举变体和结构体字段,穷举 match 和结构体字面量可能需要随之修改。

各版本的具体变化见版本与持久化契约。

  • 固定引擎版本和插件语义环境。
  • 完整走一遍:保存、重启进程、加载,再比较检查点哈希。
  • 做一次精确重放,比较最终检查点哈希。
  • 模拟一次外发超时,确认按 delivery_id 重试不会重复产生外部效果。
  • 写明版本升级时旧存档如何处理:导出后重新导入、保留旧引擎读取,还是放弃。