关于 migrating-world-v4-to-v5
migrating-world-v4-to-v5 是面向 `@workflow/world` 的 `World` 实现包的迁移 Skill,不适用于应用代码。它指导维护者处理 v5 规范变更,重点涵盖事件 ID 分配、兼容性检查和一致性测试。
主要功能
- 01审查现有 World 实现
- 02将事件 ID 迁移为编号槽位
- 03检查规范与运行时兼容性
- 04核查存储原子性和字节处理
- 05补充或运行一致性测试
适用场景
- 01升级自定义 World 后端
- 02诊断迁移后的重放故障
- 03确认 World 与运行时兼容
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vercel/ workflow --skill migrating-world-v4-to-v5安装后在 Claude Code 中输入/migrating-world-v4-to-v5确认已加载。
详细介绍
原文在翻译后有更新,译文可能不是最新。
将 World 从 v4 规范迁移到 v5
此技能适用于实现 @workflow/world 中 World 的软件包:Workflow 运行时与之通信的存储、队列和流后端。它不适用于应用代码。如果任务是升级应用的 workflow 依赖,请改用 migrating-workflow-v4-to-v5 技能;如果应用既使用 Workflow,又自带 World,请先运行该技能,再运行本技能。
使用 Vercel、Local 或 Postgres World 的应用无需使用本技能。这些 World 随 SDK 一同提供,且已符合 v5 规范。
有一项变更占据了迁移工作的重心。**必须分配事件 ID,但类型签名中看不出这一要求;跳过此步骤的 World 仍能通过类型检查、启动运行,却会在首次重放时失败。**请先完成这一部分,再进行机械式改写。不要从类型错误入手:它们只占较小的一半;解决完它们后,World 看起来已经完成迁移,实际却并非如此。
收集信息
编辑前,请确认并报告以下各项:
- **World 所在位置。**搜索
implements World、: World、World>和from '@workflow/world'。阅读它导出的工厂函数。 - **当前如何生成事件 ID。**搜索
eventId、ulid、uuid、nanoid、nextval、AUTO_INCREMENT、IDENTITY。找到生成写入存储的 ID 的确切代码行。 - **如何解决写入竞争。**阅读
events.create的实现。记录 ID 或顺序是在进程内决定(先读取再写入、内存计数器、对已加载事件使用Math.max),还是在存储中决定(唯一约束、条件写入、INSERT ... ON CONFLICT、事务)。 - **声明了哪个
specVersion。**搜索specVersion。记录它是字面值还是导入的常量。 - **存在哪些可选成员。**搜索
capabilities、analytics、getRuntimeDeadline、getEnvironment、createRunId、describeRun、getEncryptionKeyForRun、resolveLatestDeploymentId、cancelMany、experimentalSetAttributes。 - **是否会创建步骤主题。**搜索
'step'、__wkf_step、stepQueue。 - **是否会拒绝过期写入。**搜索
PreconditionFailedError、preconditionGuard、stateUpdatedAt、stateEventCount、stateCursor、412。 - **进程级状态存放在哪里。**在 World 的模块中搜索持有连接池、客户端、套接字、注册表、缓存、ULID 工厂或“仅记录一次”布尔值的顶层
const/let。逐一记录:这些变量在通过require()加载的软件包中是正确的,在打包的软件包中则不正确。 - **如何进行测试。**搜索
@workflow/world-testing和createTestSuite。尚未接入一致性测试套件的 World,应在本次迁移中接入。 - **运行存放在哪里。**询问或根据部署模型判断:单个部署是否为所有运行提供服务,还是运行会固定到创建它的部署。这决定了第 7 步的发布流程,无法从代码中判断。
对于不适用的项目,请明确报告,不要默默跳过。
步骤 1 — 事件 ID 分配
在 v4 中,事件 ID 是 ULID,由 World 随意生成。在 v5 中,事件 ID 是事件在其运行日志中的位置:evnt_ 后接一个从 1 开始、用零补齐到 26 个字符的序号,因此一个运行中的第一个事件是 evnt_00000000000000000000000001。
import { slotToEventId, eventIdToSlot, FIRST_EVENT_SLOT } from '@workflow/world';
slotToEventId(1); // 'evnt_00000000000000000000000001'
eventIdToSlot('evnt_00000000000000000000000042'); // 42
eventIdToSlot('evnt_01JQ...'); // null
使用 slotToEventId() 格式化 ID。不要自行编写补零逻辑。固定宽度可以确保字典序与位置顺序一致,因此,如果 World 使用不同的宽度补零,超过 10 个事件后,其日志的排序就会出错。
无需声明任何能力,也没有回退路径。运行时会对加载的 ID 调用 requireEventSlot(),如果 ID 不符合要求,就会抛出:Event id is not slot-numbered: <id>. This World allocates event positions the runtime cannot read.
以下四条规则约束了实现。请逐条对照 intake 项目 2 和 3 中找到的代码进行检查:
- 唯一性。 两个并发追加操作不得同时占用同一个序号。在存储层解决问题:对
(runId, eventId)设置唯一约束、使用条件写入,或使用可串行化事务。在进程内读取最大序号再加一,正是这条规则要避免的失败方式;轻量测试中它可能不会出问题,因为只有并发时才会触发。 - 连续性。 序号从 1 开始,中间不能有空缺。写入方在竞争中失败后,应从存储层重新计算序号并占用下一个空闲序号。失败后递增本地数字会留下永久空缺,运行时会因此以
CORRUPTED_EVENT_LOG错误终止运行,而不会跨过空缺重放。 - 递增并报告。
events.create()参数中携带eventCount:写入方在其重放所依据的日志中持有的事件数,因此它预期的序号是eventCount + 1。如果该序号已被占用,不要拒绝写入。 应在下一个空闲序号提交,并在成功响应的events中返回占用被跳过序号的事件,同时返回与之匹配的cursor和hasMore。对于并行扇出,计数过期是常态;拒绝过期计数会把运行时有意并发发起的写入串行化。没有eventCount的 create 来自未加载日志的调用方(排队步骤的请求体、带外写入方),应始终接受。 - 在提交时分配。 在追加事件的同一个操作中占用序号,绝不要提前分配。这样才能确保读取方的日志是运行日志的一个前缀,而不是带有空缺的前缀:没有任何事件能落到读取方已经越过的序号之后。在请求处理程序中分配序号、稍后才提交的 World,会破坏每次重放都依赖的这一特性。
对于在 (run_id, event_id) 上设有唯一键的 SQL 存储,满足以上四条规则的做法是在插入操作中计算 ID,并由约束处理竞争:
INSERT INTO events (run_id, event_id, event_type, data)
SELECT $1,
'evnt_' || lpad((coalesce(
(SELECT cast(substring(prev.event_id from 6) AS bigint)
FROM events prev WHERE prev.run_id = $1
ORDER BY prev.event_id DESC LIMIT 1), 0) + 1)::text, 26, '0'),
$2, $3
ON CONFLICT (run_id, event_id) DO NOTHING
RETURNING event_id;
没有返回行意味着另一个写入方占用了该序号。重试同一条语句:它会从已推进的存储层重新读取最大值,这就是递增。@workflow/world-postgres 正是这样做的:设置有界重试次数,前几次立即重试后采用带抖动的退避,并通过 DO NOTHING 吸收冲突,而不是抛出错误,因为这些插入在事务中执行,错误会导致事务无法继续。
具体语句并不重要,重要的是这一特性:在针对唯一约束所保护的行执行的同一个原子操作中计算序号并插入行,因此失败方会基于存储层重试,而不是基于自己记住的数字。没有条件写入的存储层则需要使用可串行化事务,而不是进程内锁;进程内锁只能对单个进程中的写入方排序。
然后,只要已提交序号大于 eventCount + 1,就返回被跳过的区间。eventCount 报少了是安全的,报多了则不安全:低于写入方真实位置的计数只会扩大所报告的区间,写入方会丢弃其日志中已有的事件;高于真实位置的计数会使 World 少报告写入方缺失的事件,形成写入方永远无法获知的空缺。
步骤 2 — 声明规范版本
specVersion 是 World 实现的协议版本,也是它创建的每次运行上标记的版本号。请调用辅助函数,而不是导入常量:
import { mintedSpecVersion } from '@workflow/world';
export function createWorld(): World {
return {
specVersion: mintedSpecVersion(),
// ...
};
}
运行时会在创建或重放任何内容之前,将声明与受支持的版本范围进行核对;如果 World 的版本不在范围内,就会拒绝该 World,并指出版本范围以及 World 声明的版本。最低版本是引入按槽位编号的 ID 的版本,因为低于该版本的 World 会分配运行时无法读取位置的 ID。最高版本是运行时能够读取的最高版本。
mintedSpecVersion() 是一个函数,因为 World 标记的版本是部署时的选择:默认情况下,它返回密封日志版本;设置 WORKFLOW_SEALED_LOG=0 选择让新运行不使用该版本时,它会返回低一个版本。两者都在可接受范围内。请在 createWorld() 中调用它,而不是在模块加载时缓存结果,这样一个进程就能在两种模式下创建 World。
请将以下任一情况报告为问题,而不要置之不理:
- 硬编码的数字。这会让 World 落后于下一次版本升级,并导致它被同时发布的运行时拒绝。
- 将
SPEC_VERSION_CURRENT或SPEC_VERSION_SUPPORTS_SLOT_IDENTITY用作声明。这里它们只是换了名字的字面值,因为两者都不会随密封日志设置变化。 - 假定每次运行都与 World 当前声明的版本一致的代码。运行的版本在创建时持久化,并在其生命周期内保持不变,因此应从运行中读取版本。
密封日志与 noop
密封日志版本允许采用一种替代方案,无需在提交时(步骤 1)分配位置:先通过每次运行各自的计数器分配位置,再进行提交,这样并发写入者就不会争抢同一位置;随后,在读取时,如果能够确定某个位置已被放弃,就写入一个 noop 事件,以恢复位置的连续性。noop 占据其位置,但不表示任何内容——重放时会跳过它,不会传递它,也不会推进确定性时钟。
对于大多数迁移,这一项无需改动;请明确说明这一点,而不要跳过它:在提交时分配位置的 World 已符合要求,且永远不会发出 noop,因为没有任何写入会留下空位。只有在 World 预先分配位置时,才需要实现密封机制;此时还必须确保绝不返回内部有空洞的页面——返回空洞下方的连续前缀,并让下一页在位置确定后从该位置继续读取。
始终适用的是读取端的要求:用户不能创建 noop,不得将其发送给 events.create(),并且只有 World 自身的读取路径可以写入它。如果 World 会在读取时校验事件类型,请确保 noop 能通过解析。
步骤 3 — 应用机械式改写
这些是签名和模块结构的变更。只在出现相应模式的地方应用每项改写。
Streams 移至 streams 命名空间,且 runId 前置
| v4 | v5 |
|---|---|
writeToStream(name, runId, chunk) | streams.write(runId, name, chunk) |
writeToStreamMulti(name, runId, chunks) | streams.writeMulti(runId, name, chunks) |
closeStream(name, runId) | streams.close(runId, name) |
readFromStream(name, startIndex?) | streams.get(runId, name, startIndex?) |
getStreamChunks(name, runId, options?) | streams.getChunks(runId, name, options?) |
listStreamsByRunId(runId) | streams.list(runId) |
参数顺序已反转,因此,如果移动方法时不交换参数,就会把流名称当作运行 ID 传入;只要两者都是 string,类型检查就会通过。readFromStream 原本没有 runId;streams.get 要求传入该参数,因此请传入所属运行。
streams.streamFlushIntervalMs 用于设置刷新窗口。v5 的默认值为 0,因此第一个数据块会立即刷新。只有在你有意合并写入时,才设置该值。
steps.get() 需要 runId
第一个参数原本是 string | undefined,现在是 string。如果 World 过去只按 ID 查找步骤,现在需要将运行纳入其键或索引。
events.listByCorrelationId() 需要 runId
关联 ID 用于标识某次运行中的步骤、钩子或等待,而非跨运行标识。查询时应限定到一次运行。按事件 ID 分页的 World 也需要在游标比较中加入运行 ID,因为现在两个运行可能会有相同的关联 ID。
analytics.events.listByCorrelationId() 也接收相同的 runId,并且现已弃用:它是 analytics.events.list({ runId, correlationId }) 的特例,并将在下一个主版本中移除。目前先迁移,不要基于它构建任何新内容。上文的 storage events.listByCorrelationId() 未弃用,并保留自己的端点。
导出 createWorld() 工厂
第一方包已移除 createLocalWorld() 和 createVercelWorld(),现在导出 createWorld()。请采用这种形式。参数不变,只是重命名。
World 在构建时注入
World 的选择是静态的,由构建过程解析到宿主包中,而非在运行时动态查找。升级后请确认 World 仍可解析,且其模块图能经受打包。依赖运行时通过环境变量计算路径并执行 require 的 World 将无法被找到。
第 4 步 — 将进程级状态移至 globalThis
这个问题不会显现。它可以通过类型检查,在隔离环境中也能通过测试,但只会在打包了 World 的宿主中失败。
模块顶层的 const 或 let 是每个模块实例各自一份,而非每个进程一份。Next.js 会将其服务器输出编译成相互独立的模块图,而打包后的模块会分别编译到每个模块图中,各自拥有模块作用域绑定。运行时会在进程范围内缓存 World 对象,但 World 闭包捕获的模块状态仍局限于各层,因此 World 在请求时会访问的所有内容也必须是进程级的。
将 intake item 8 中发现的问题全部集中到一个对象中:
import { globalSingleton } from '@workflow/utils';
const state = globalSingleton('@my-org/world-foo//connections', 1, () => ({
pool: undefined as Pool | undefined,
warnedOnce: false,
}));
globalSingleton 通过 globalThis 上的 Symbol.for 为对象建立键,因此每个模块副本都会获取同一个对象。第二个参数是形状版本:当对象形状发生不兼容变更时,应递增版本,这样共享该进程的旧版包副本会保留自己的状态,而不会误读你的状态。let 无法通过引用共享,因此“仅记录一次”的闩锁应成为一个字段,而非继续作为变量。
即使无需更改,也要报告这一点,并说明它能避免的故障。在 @workflow/world-vercel 中,受影响的是 WebSocket 事件传输:队列消费者在一层的注册表中注册了通道,而写入路径却在另一层的空注册表中查找,因此进程运行期间,每个事件都会回退到 HTTP;没有任何日志,也没有测试失败。
步骤 5 — 契约变化
这些变化不会改变任何签名。仅靠类型移植的 World 可以通过编译,但运行时行为会出错。
- 挂起调度会批量进行。 不对称的
{ timeoutSeconds }等待返回契约已废弃。等待现在是携带delaySeconds的普通队列续接,而挂起调度会将等待和步骤作为一个并行批次派发。假定每次挂起只会收到一条消息的队列需要处理批次。 - 步骤队列主题已废弃。
'step'队列类型不再存在。排队的步骤会通过工作流主题传递,在载荷中携带stepId和stepName,并在组合流程处理程序中执行。删除任何__wkf_step_*主题配置。 preconditionGuard能力已移除,其存在的理由也不复存在。 如果 World 曾拒绝创建快照落后于日志的事件,现在可以删除这段代码以及相关的stateUpdatedAt/stateEventCount/stateCursor处理逻辑。增量并报告已取代它:过时的重放只会增加一次合并操作,而不是导致拒绝。对于在提交之外分配槽位、宁愿拒绝也不愿报告的 World,PreconditionFailedError仍然存在;遵循步骤 1 的 World 则不属于这种情况。- 能力默认关闭。 未声明的能力只会影响性能,不会影响正确性,因此部分完成的 World 在补齐功能期间仍然正确。反之则不成立:声明了实际未执行的能力,会移除运行时所依赖的保护。只有在行为实现后才设置标志。
- 事件创建可能返回增量。
events.create()除了创建的事件之外,还可能通过events/cursor/hasMore返回其他事件。除了步骤 1 中的增量并报告情况之外,运行时还会利用这一机制,在run_started、携带sinceCursor的步骤终态写入,以及携带preloadEvents的hook_received写入时,跳过后续的events.list。这三种情况都是可选优化:仅返回创建的事件仍然正确,只是会多一次往返。 - 终态运行会拒绝
step_started。 运行是否存活由 World 负责强制执行,因为只有它能在一次操作中同时看到运行记录和认领记录。即使步骤记录仍显示为running,也要拒绝运行状态已为completed、failed或cancelled的step_started。这种组合意味着某次更早的投递已认领该启动请求,但现在又收到重复投递;接受它会执行一个其结果永远不会被读取的步骤主体。对于终态运行,step_completed和step_failed仍会被接受,以便已在执行中的步骤可以记录结果。@workflow/world-local和@workflow/world-postgres在此情况下都会抛出RunExpiredError。这是关键约束,而非防御性处理:步骤执行消息现在携带运行的不可变身份(WorkflowInvokePayload上的runContext),使消费者可以跳过阻塞式的runs.get;运行的状态则有意不包含在其中。认领是剩下的唯一存活性检查。如果 World 的队列通过字段白名单重新序列化载荷,必须原样传递runContext,否则每次步骤派发都会悄然退回到额外的往返请求。 runs.list接受状态数组。ListWorkflowRunsParams.status的类型为WorkflowRunStatus | WorkflowRunStatus[];传入数组时,状态符合其中任一项的运行都会匹配。status: []不匹配任何内容,与 SQL 的IN ()行为一致,并且不同于省略该字段。若 World 将此参数声明为普通字符串,它可能仍能对照旧类型编译,但运行时会按status = '[object Array]'进行筛选,也可能因存储后端不同而抛出异常。请实现该功能,或在数组形式下明确报错,而不要静默返回错误的分页结果:@workflow/world-vercel目前采用后者,因其后端尚不支持多状态筛选,会抛出带有INVALID_ARGUMENT的WorkflowWorldError。
步骤 6 — 值得采用的可选接口
这些都不是必需项,运行时会绕过每个缺失项。请在报告中说明 World 缺少什么,而不是未经提示就全部实现。
capabilities(hookRetention.active、hookResumeDedup、deploymentAffinity、maxConcurrency)、analytics、analytics.events.getMany()、runs.experimentalSetAttributes、runs.cancelMany、runs.waitForTerminalStatus()、events.createBatch()、streams.createWriteSession()、getRuntimeDeadline()、getEnvironment()、createRunId()、describeRun()、getEncryptionKeyForRun()、resolveLatestDeploymentId()、close()。
其中有四项值得主动提及,因为它们的缺失会带来实际影响,而不只是报告中少一项:
- 缺少
getRuntimeDeadline()时,内联重放预算固定为两分钟,因此超时设置较长的宿主每次调用所能完成的工作会少于实际可能的工作量。 - 缺少
close()时,CLI 命令和短生命周期进程无法在不调用process.exit()的情况下干净地退出。 - 缺少
events.createBatch()时,一次挂起操作中的step_created和wait_created写入各自都需要一次往返。实现此方法本身就代表声明——没有单独的标志——因此它必须保证每次尝试都是原子的,在竞争失败时不留下任何内容,否则就应完全不实现。它无法表达run_created、run_started、run_cancelled、hook_created、hook_disposed或attr_set;World 收到其中任一项时都会拒绝整个批次。 - 缺少
runs.waitForTerminalStatus()时,await run.returnValue会退回到按间隔轮询,而不是长轮询。 - 缺少
streams.createWriteSession()时,运行时会通过无状态的write/writeMulti/close方法写入。这种方式没有问题,但每次写入都要付出传输层初始化的成本。如果在某个写入器的分块之间保持状态能带来好处(例如保持连接打开),就应实现此方法。运行时每个内存中的WritableStream至多创建一个会话,并传入writerId,因此会话的write(chunkSeq, chunks)接收的序列号仅属于该写入器,而非整个流。这样 World 就能对写入同一流的并发写入器进行排序。所有先前写入都持久化后,close()才能完成;dispose()是可选的,用于释放传输资源,但不会结束流。
一旦实现 analytics,以下两项限制就会约束其行为;运行时会在进程内、请求离开调用方之前强制执行,并以 RangeError 报错。到达 World 的请求已经符合这些限制,因此应将其视为契约,而不是再次验证:运行范围内的列表(steps.list、events.list、waits.list)中,pagination.limit 默认为 40,上限为 1000;跨运行列表(runs.list、attributes.list、hooks.list)中,上限为 100;analytics.events.getMany() 接受同一运行中的 1 到 100 个事件 ID,不分页,并且对于 analytics 尚未摄取的 ID,会直接省略而不抛出错误。
步骤 7 — 部署
在用户部署之前,请在迁移报告中发出警告:
存储中已有的运行无法由新代码重放。 采用 ULID 编号的运行无法按位置读取,运行时会拒绝处理,而不会自行猜测。不存在混合方案模式,也不存在按运行回退的机制。
后续步骤取决于接入清单第 10 项。如果运行在创建它的部署上执行,问题会自行解决:这些运行会在启动它们的构建版本上完成,不会遇到新代码。如果一个部署为所有运行提供服务,那么必须先在 4.x 构建版本上排空正在运行的任务,再部署 v5 World,否则这些任务会失败。
验证
先接入一致性测试套件。这是发现第 1 步工作有误的最省事方式:
import { createTestSuite } from '@workflow/world-testing';
createTestSuite('@my-org/my-world'); // or a path to the built entrypoint
测试套件会启动一个将 WORKFLOW_TARGET_WORLD 设为该值的服务,并针对它运行实际工作流。其 numbers events by position 测试会在某个 World 的 ID 无法解码为槽位、运行中的槽位编号不是从 1 开始且连续,或 ID 不是规范形式时失败。这样,原本要到首次重放时才会出现的失败,就会直接显示为一行测试输出。
然后按顺序运行并报告实际输出:
- 构建并进行类型检查,检查 World 包。
- 一致性测试套件。
- World 自身的测试套件。
- 针对实际应用进行端到端运行,覆盖:一个在步骤处挂起的运行,以及一个在等待处挂起的运行;一次并行扇出,让并发的
events.create()调用争抢同一个槽位;一个在运行已推进后恢复的钩子;一个写入并读回的数据流,包括在读取器连接前已关闭的数据流。
轻量测试容易漏掉并发问题。如果 World 的测试从未对同一个运行同时发起两个 events.create() 调用,就在宣告迁移完成前添加一个这样的测试。
如果符合以下任一情况,就判定迁移失败:
- 事件 ID 是通过
slotToEventId()以外的方式生成的 - 槽位是在提交事件的操作之外,通过读取最大值或计数器来选取的
- 槽位在提交之前就已分配
-
events.create()拒绝、抛出错误或重试其eventCount + 1槽位已被占用的写入,而不是递增到下一个空闲槽位 - 递增后的写入返回时,
events/cursor/hasMore中未包含被跳过的事件 - 不携带
eventCount的创建请求被拒绝 -
specVersion是字面量、SPEC_VERSION_CURRENT或SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,而不是mintedSpecVersion() -
streams.*调用仍沿用 v4 的参数顺序(名称在 runId 之前) - 无需 run ID 即可访问
steps.get或listByCorrelationId - 处于终止状态的运行仍接受
step_started -
ListWorkflowRunsParams.status被类型化或处理为单个状态,且数组形式既未实现也未明确拒绝 - 声明了某项能力,但未实现其行为
- 模块作用域仍持有池、客户端、套接字、注册表、缓存、ID 工厂或只记录一次的锁存器
- 未接入
@workflow/world-testing,或未报告其结果 - 未给出第 7 步中的 rollout 警告
- 未实际运行并报告构建、类型检查或测试结果
必需的输出格式
## Summary
## Event ID Allocation
## Interface Changes
## Process-Wide State
## Contract Changes
## Optional Surface Not Implemented
## Rollout
## Verification
## Open Questions
## Event ID Allocation说明现在在哪里计算槽位、如何解决争用,以及递增后的写入如何报告跳过的区间。引用代码。## Process-Wide State列出发现的每个模块作用域单例及其迁移去向;若没有,则说明没有。## Optional Surface Not Implemented列出未实现的内容及每项缺失带来的影响,供用户自行决定。## Rollout包含第 7 步的警告,并说明此部署适用其中哪种情况。
参考资料
- 将 World 升级到 v5:https://workflow.dev/worlds/upgrading-to-v5
- 构建 World:https://workflow.dev/worlds/building-a-world
- 事件 ID:https://workflow.dev/docs/how-it-works/event-sourcing#event-ids
- v5 新增内容(面向应用):https://workflow.dev/docs/whats-new
- 参考实现:https://github.com/vercel/workflow 中的
packages/world-local和packages/world-postgres