跳到主内容
创建时间:最后更新:作者:白川

实现插件热重载与状态迁移

用同一个对象引用验证代码更新、状态保留、迁移和候选版本失败后的可用性。

实现插件热重载与状态迁移

进程没有退出,方法已经更新,旧状态却还在:这才是热重载需要处理的问题。重新 import 文件只能获得新代码,不能回答运行中的对象、旧调用和状态该怎么办。

本文接着上一篇的检查计数插件运行。先把每次加 1 改成加 2,再提升状态版本,最后故意让候选版本加载失败。每一步都有可观察的数值。

示例固定 Chord 1.5.0,目录是 columns/coding-agent/examples/plugin-development/。先理解 contract.ts 与 facet.ts,再运行 npm run reload。

同一个名字匹配同一份实现

重载始终使用 facet 名 lesson/inspection、契约名 lesson.inspection、相同 input 和 cell 名 count。如果每次创建随机名字,宿主看到的是新增对象,无法按原身份接续状态。

inspectionFacet(2) 只改变方法行为,状态版本仍为 1;inspectionFacet(2, 2) 把状态版本提升到 2,并用迁移函数转换旧值。这是教学设定:真实项目应该按业务含义迁移,不能给旧数据随意加一个常数。

完整运行过程

ts
import { createHost } from "@earendil-works/chord"
import { Inspection } from "./contract.js"
import { inspectionFacet } from "./facet.js"

const host = createHost({ name: "server", self: { kind: "server" } })
try {
  await host.load([inspectionFacet(1)])
  const ref = await host.get(Inspection, {})
  try {
    await host.root((ctx) => ref.run({}, ctx))
    console.log("before", ref.count.value)
    const changed = await host.reload([inspectionFacet(2)])
    await host.root((ctx) => ref.run({}, ctx))
    console.log("after-code", ref.count.value, changed.changed)
    const migrated = await host.reload([inspectionFacet(2, 2)])
    console.log("after-migration", ref.count.value, migrated.gated)
    try {
      await host.reload([
        {
          name: "lesson/inspection",
          setup() {
            throw new Error("故意制造候选版本失败")
          },
        },
      ])
      throw new Error("预期重载失败,却意外成功")
    } catch (error) {
      if (!(error instanceof Error) || !error.message.includes("故意制造")) {
        throw error
      }
      console.log("reload-rejected", error.message)
    }
    await host.root((ctx) => ref.run({}, ctx))
    console.log("after-rejected-reload", ref.count.value)
  } finally {
    ref.release()
  }
} finally {
  await host.close()
}

关键输出是:

text
before 1
after-code 3
after-migration 103
reload-rejected ...故意制造候选版本失败...
after-rejected-reload 105

第一次调用得到 1。方法更新后同一个引用再次调用,结果是 3,说明代码改变而旧值没有清零。状态版本提升后变成 103,说明迁移消费了已有值。候选版本失败后调用仍能变成 105,说明本例旧实现仍可服务。

changed 和 gated 报告帮助定位发生了什么,但不把内部地址格式当作稳定业务 API;断言具体行为比硬编码打印格式更有价值。

契约版本和状态版本不是一回事

变化需要判断本例
方法内部算法
调用接口是否兼容
加 1 改为加 2
cell 值的含义或 Schema
是否需要版本提升及迁移
状态版本 1 → 2
方法参数或公开状态结构
调用者契约兼容性
未改变,需另做兼容实验
插件外部副作用
是否可准备、提交和补偿
未执行外部写入

迁移接收 unknown,验证旧版本和值后才能转换。跳过多个版本时,迁移也要明确处理 from,不能永远假定来源只比当前少一版。Schema 发生变化却不提升状态版本,不能期待宿主猜测迁移意图。

运行中的调用如何处理

兼容代码更新可以让既有调用按旧代码完成,新调用使用新代码。需要迁移或替换不兼容资源时,运行时会进入 gate 等待相应调用边界;等待有 deadline,不能保证所有长任务永远都能顺利迁移。

本例刻意在每个 root 结束后发起 reload,没有验证并发长任务、强制退休或集群投票。生产验收应再加入:重载期间取消、未完成调用、迁移抛错、资源打开失败、重复重载,以及远端 host 无响应。方法的外部写入需要独立幂等策略。

失败回退的边界

本例让候选 facet 的 setup 立即抛错,观察到 reload 被拒绝且原实现继续工作。这个结果不能扩大为“所有失败都能撤销”:如果插件在准备阶段直接发了外部请求,那次请求可能已经发生。

应把不可撤销操作从初始化中移出,让资源按运行时生命周期声明,并为外部副作用设计提交、幂等与补偿。热重载保存的是运行时能够管理的对象关系;操作系统、数据库和第三方服务仍有各自的事务边界。

依据与版本

示例依据已发布的 @earendil-works/chord@1.5.0 包内 README、类型声明与本地执行结果。在线主分支已有 v2 设计材料,createPlugins、plugins/runtime 等写法不能直接混入本篇的 1.5 示例。