跳转到内容

从无锡发出、按路线投递的通信

无锡的一位指挥官亲自携带一封密封信件,收件人只有一位。程序把这次投递运行两次:第一次,指挥官知道的收件地址在无锡的投递区;第二次,地址在北京的投递区。每条路线都按指挥官自己知道的站点、时刻表和地址规划。

这个示例回答的问题是:信件怎样只凭承运人所知的信息穿过交通网络,同时把路线和每一步投递都记录下来?

网络是合成数据,站名、时刻表和行程时长都是演示用的。

  • canwu-correspondence 模拟领域扩展。其中的 CorrespondencePlugin 与 canwu-information 的 InformationPlugin 一起运行。一次通信(CorrespondenceOperation)就是一条定向消息,连同它的投递和运输过程。
  • 持有人相对知识:站点、连接和地址记在某个持有人(KnowledgeHolderRef)的账本里,规划时在一个有记录的读取切面(KnowledgeReadCut)上读取这份账本。参见读取持有人知识。
  • canwu-routing 的 plan_route 与 canwu-transport 的 TransportExecution:后者逐段执行,并在路段之间记录保管权交接 Handoff。参见路线规划与运输。
  • canwu-information 的信息生命周期:发送记录(Dispatch)及其投递尝试(DeliveryAttempt)。参见编码传递与中途截取。
  • 由操作定址随机抽样决定的自动通信机会。
Terminal window
cargo run -p canwu-correspondence --example routed_correspondence

输出:

Wuxi local delivery: 1 leg(s), arrival minute 30
wuxi/hub -> wuxi/delivery/recipient via Horse
Wuxi to Beijing: 2 leg(s), arrival minute 3060
wuxi/hub -> beijing/station via Rail
beijing/station -> beijing/delivery/recipient via Horse

arrival minute 是计划中的 estimated_arrival_at,从运行开始按分钟计。北京方案在第 1 小时搭上直达列车,两天后、即第 2940 分钟到达北京站,再骑马两小时: 2940 + 120 = 3060。经南京的路线到北京站要晚一天。

一次通信从承运人知识到送达的流程,以及事故与恢复的接入点. 查看图表源码.
查看图表源码
flowchart TD
  K["承运人账本(本例即发送者):站点、时刻表、地址"]
  O["通信机会或决策票据"]
  C["initiate_correspondence_v1"]
  P["在读取切面上按账本规划 RoutePlan"]
  A["发送记录激活,创建 DeliveryAttempt"]
  L["逐段运输,每个中转站记录 Handoff"]
  D["投递尝试送达,发送记录完成"]
  X["灾害:同一投递尝试的新 ItineraryRevision"]
  I["截取:写入 Access 记录,投递继续"]
  F["承运人被扣押或迟到:投递尝试失败"]
  R["发送者提交 resolve_correspondence_v1"]
  O --> C --> P
  K --> P
  P --> A --> L --> D
  L -. 灾害 .-> X
  X -. 改道 .-> L
  L -. 截取 .-> I
  L -. 扣押或超期 .-> F
  F -.-> R
  R -. 重试,新建投递尝试 .-> P

实线是示例实际走过的路径;虚线是事故与恢复,由本页末尾介绍的测试覆盖。

代码在 routed_correspondence.rs 及其夹具 examples/support/mod.rs 中。run(long_distance) 为每个请求新建一个模拟。传给 Canwu::new_with_plugins 的 1940 是随机种子。

scenario_with_prepared_dispatch 从已弃用的兼容场景 Canwu::demo(1) 中取出指挥官(发送者)和观察者(observer,收件人),再添加 sealed-letter 渠道、信件内容、表现形式(representation)和一条发送记录:

&DispatchPayload {
status: DispatchStatus::Prepared,
target: DispatchTarget::Addressed(vec![recipient.clone()]),
prepared_at: snapshot.initial_time,
dispatched_at: None,
completed_at: None,
},

插件只接受处于 Prepared、收件人正好是这一位,且发送者和渠道配置(channel profile)都与请求一致的发送记录。

network_seed 返回一份 NetworkKnowledgeSeed。北京版本知道一趟直达列车、一条经南京的路线和两条最后一程的道路。直达列车用 TraversalModel::Departures,在第 0、5、10 天的第 1 小时发车:

connection_departure(
"wuxi-beijing-direct",
"wuxi/hub",
"beijing/station",
TransferMode::Rail,
departure,
SimDuration::days(2),
),

种子里还有一条 KnownAddress,把收件人对应到 beijing/delivery/recipient(本地运行时为 wuxi/delivery/recipient)。run 以 KNOWLEDGE_INGRESS 为 KnowledgeHolderRef::Person(sender) 提交这份种子,插件把每个端点、连接和地址作为知识记录写入发送者的账本。

在同一个边界里,run 还提交了 OPPORTUNITY_INGRESS:

CommunicationOpportunityRequest {
operation_key: operation_key.to_owned(),
sender: EntityRef::Person(sender),
candidates: vec![recipient.clone()],
reason: "routine correspondence".to_owned(),
probability_per_mille: 1_000,
automatic: true,
}

插件先做一次操作定址随机抽样,判断机会是否出现,再抽一次,从 candidates 中选出收件人,所以重放时结果相同。这里 probability_per_mille 为 1_000(必然发生),候选人又只有一位,因此每次都选中观察者,机会记录的状态为 SelectedAutomatic。

let request = InitiateCorrespondenceRequest {
operation_key: operation_key.to_owned(),
sender: EntityRef::Person(sender),
recipient,
carrier: KnowledgeHolderRef::Person(sender),
channel_profile: "sealed-letter".to_owned(),
origin: RoutingNodeRef::new("wuxi/hub"),
due_at: canwu.time() + SimDuration::days(10),
prepared_dispatch,
delivery_attempt_operation: InformationOperationId::new(
"example.correspondence",
format!("{operation_key}-attempt"),
),
routing_policy: canwu_api::RoutingPolicy::default(),
capacity_admission: CorrespondenceCapacityAdmission::Unconstrained,
execution_id: canwu_api::TransportExecutionId(if long_distance { 2 } else { 1 }),
automatic_opportunity: Some(opportunity_ref(operation_key)),
carrier_delegation: None,
};

correspondence_command 把它包装成插件命令 initiate_correspondence_v1,由 Issuer::System 以 CommandAuthority::no_responsible_actor(...) 签发。只有存在 key、发送者和收件人都相同的 SelectedAutomatic 机会时,插件才接受这种权限。承运人就是发送者,所以 carrier_delegation 为 None。

plugin.rs 中的 settle_start 读取承运人的账本,解析地址,然后规划路线:

let (snapshot, address) = carrier_planning_snapshot(
view,
&admitted.request.carrier,
&admitted.request.recipient,
context.at,
)?;
let route_plan = plan_route(
&snapshot,
&RoutingRequest {
origin: admitted.request.origin.clone(),
destination: address.destination.clone(),
departure_at: context.at,
policy: admitted.request.routing_policy.clone(),
},
)
.map_err(|error| invalid_record(error.to_string()))?;

默认的 RoutingPolicy 选最早到达的路线。插件把计划、地址、读取切面和规划证据存入 CorrespondenceOperation,把通信机会标为 Consumed,再请信息插件激活发送记录。激活时创建第 1 次投递尝试,截止时间取请求中的 due_at。

插件按各段的计划时间自己调度 ProgressAction::StartLeg 和 CompleteLeg 输入,并拒绝上层应用提交的进度。非最后一段完成时,complete_leg 在该站记录一次计划内的 Handoff;北京那次运行在 beijing/station 记录一次。最后一段到达时:

let status = if context.at <= operation.current_due_at {
DeliveryAttemptStatus::Delivered
} else {
DeliveryAttemptStatus::Failed
};

投递尝试送达后,插件完成发送记录,操作变为 CorrespondenceStatus::Settled。run 最多推进 80 个边界,直到 typed_domain_record(&correspondence_operation_ref(operation_key)) 显示 Settled,再由 print_plan 打印 route_plan。

  • 两个请求走的是同一套插件代码,区别只在种子数据和地址。
  • 计划只用承运人账本里的连接。
  • RoutePlan.estimated_arrival_at 是估计值,DeliveryAttempt.due_at 是截止时间。迟到会让投递尝试失败,操作标为 DeadlineMissed,due_at 保持原值。
  • 每一步都有记录:带读取切面的规划历史、运输路段与交接,以及带版本的发送记录和投递尝试。
  • 去掉直达列车。 在 network_seed 中删除 wuxi-beijing-direct。北京方案会变成经 nanjing/station 的三段路线,在第 4500 分钟到达:第 25 小时到南京,第 73 小时到北京站,再骑马两小时。
  • 错过截止时间。 把 due_at 改成 canwu.time() + SimDuration::days(1),让循环在 operation.status.is_terminal() 时返回,并打印 operation.status。本地信件照常结算;北京信件在第 3060 分钟到达,晚于第 1440 分钟的截止时间,以 DeadlineMissed 结束。
  • 压制通信机会。 把 probability_per_mille 设为 0。机会记为 Suppressed,插件拒绝命令,run 等不到操作出现,最终 panic。

示例在两封信都结算后就结束了。生命周期的其余部分在 crate 中实现,并由测试覆盖: routed_delivery.rs 覆盖通过决策发信、事故和恢复; gap_g25_correspondence_delegated_carrier.rs 覆盖受托承运人; gap_g26_correspondence_carrier_seized.rs 覆盖承运人扣押。这些测试大多最后会恢复快照,并用 Canwu::replay_from_journal 重建运行(精确重放)。

  • 通过决策发信。 correspondence_decision_ticket 构造一张决策票据,其中的 send 选项携带通信命令。决策来源是发送者本人时,插件接受这条命令;routed_delivery.rs 走的就是这条路径。
  • 事故。 应用通过 INCIDENT_INGRESS 提交带 probability_per_mille 的 CorrespondenceIncidentRequest。插件用操作定址随机抽样判定是否发生,并把事故记在操作上;在不适用的状态下到达的事故作为已抑制的证据保留。
    • Disaster 让当前路段失败,去掉被阻断的连接后从当前站点重新规划,结果是改道:同一次投递尝试的后继 ItineraryRevision。没有已知路线时,操作以 WaitingForRoute 等待。
    • Interception 为截取者写入一条 Access 记录,投递继续。
    • CarrierSeized { seized_by, custody_handoff } 即承运人扣押。当前路段失败,记录一次终态的 HandoffKind::Seizure 交接,投递尝试及其运输执行都以失败关闭。只有承运人会收到一条 attempt_report 知识记录,其中不写扣押者。
  • 恢复。 发送者的决策命令 resolve_correspondence_v1 带一个 CorrespondenceRecoveryAction。ReplanCurrentAttempt 在承运人得知新连接后,让处于 WaitingForRoute 的投递尝试继续。RetryDelivery 发起投递重试:新建一次投递尝试,以 DomainReference 角色 "previous_attempt" 指向旧尝试,并使用新的截止时间和 TransportExecution。 FinalizeDispatch 完成发送记录,操作以 Failed 或 DeadlineMissed 结束。在此之前,投递尝试失败后发送记录保持 Active。
  • 受托承运人。 受托承运人先以自己的命令权限签发 delegate_carrier_v1,其中的 DelegationClaimV1 把承运人写为 performed_by、发送者写为 performed_for,并列出 carry_correspondence 能力。从下一个边界起,发送者在 InitiateCorrespondenceRequest::carrier_delegation 中引用这条命令。此后规划读取承运人的账本,引擎不向发送者发布其中任何内容。声明必须覆盖每次发送,包括重试;同一承运人为同一发送者签发的新委托会取代旧委托。
  • 运力。 CorrespondenceCapacityAdmission 只有 Unconstrained 一个变体,规划和投递都忽略运力。运力池属于 canwu-movement,参见路线规划与运输。

打开可运行示例

阅读投递、改道与重试测试

阅读受托承运人测试

阅读承运人扣押测试