Inngest 执行引擎源码解析:SDK 请求版本(Request Version)如何守护步骤函数的重放一致性
【免费下载链接】inngestThe leading workflow orchestration platform. Run stateful step functions and AI workflows on serverless, servers, or the edge.项目地址: https://gitcode.com/GitHub_Trending/in/inngest
本文基于 Inngest 仓库内的设计文档 pkg/execution/state/README.md,深入解析执行引擎中的 SDK 请求版本(Request Version / Hash Version)机制:它为什么是步骤函数 exactly-once 语义的基石、版本值在状态元数据与 SDK 请求中如何流转、x-inngest-req-version响应头协议如何在 HTTP 驱动中落地,以及版本值如何门控 force step plan 等特性。读完后,你将能够理解 Inngest 执行器(executor)与 SDK 之间基于"版本握手"的一致性协议,并能定位到相关源码与测试。
一、为什么"请求版本"对重放如此关键
Inngest 的核心模型是有状态步骤函数(stateful step functions):函数被拆分为多个步骤,每次执行只运行一个尚未完成的步骤,已完成步骤的结果(memoized state)被持久化,下一次执行时通过请求体回放给 SDK。README 开篇即点明了这一版本机制存在的根本原因:
driver.SDKRequestContext.HashVersion&&state.Metadata.RequestVersionindicate the request version used to POST data to the SDK. This is critically important for replay; changing the request payload breaks the exactly-once guarantees of functions.
也就是说,请求版本标识的是"向 SDK 投递请求数据(以及步骤哈希方式)的格式代际"。重放(replay)依赖一个前提:同一步骤在每次重试、每次续跑时,SDK 收到的输入与哈希方式必须完全一致,否则 SDK 侧的步骤幂等键会发生变化,"已完成的步骤"就识别不出来,exactly-once 保证随之瓦解。
文档进一步列出了请求版本可能变化的三类来源:
- 步骤哈希方式变化(Step hashing changes)——哈希方式变了,所有步骤的幂等键都变;
- 输入类型变化(Input types change);
- 输入数据变化(Input data changes,例如按步骤记录的 per-step errors)。
1.1 版本字段在源码中的两个落点
README 提到的两个标识字段,在当前源码中对应如下:
- 出站请求侧:pkg/execution/driver/request.go 中的
SDKRequest结构体携带Version int字段(JSON 字段名version),注释明确写道"A value of -1 means that the function is starting and has no version"。注意 README 中写的是SDKRequestContext.HashVersion,这是该字段的历史形态;从源码结构看,当前版本字段已收敛到SDKRequest.Version,而SDKRequestContext本身不再包含哈希版本(见 request.go 中的 SDKRequestContext 定义)。 - 持久化状态侧:执行元数据(metadata)的配置结构体中保存
RequestVersion int,注释为"RequestVersion represents the executor request versioning/hashing style",定义于 pkg/execution/state/v2/state_metadata.go,并通过 protobuf 序列化进出 Redis 等状态后端(见 state_proto.go 的双向转换)。这保证了版本值跨进程、跨请求稳定可读。 - 哨兵常量:
-1在常量层被赋予语义——pkg/consts/consts.go 定义RequestVersionUnknown = -1。
构建出站请求时,执行器把元数据里的版本直接写进 SDK 请求:pkg/execution/driver/driver.go 中req := &SDKRequest{ ..., Version: md.Config.RequestVersion, ... },即持久化元数据是版本的权威来源,每次请求只是把它搬运到 SDK 面前。
二、版本值的取值语义:-1、0 与 1
README 给出了完整的取值约定,这是理解整个协议的关键表格:
| 版本值 | 语义 |
|---|---|
-1 | 哈希版本未设置(unknown),必须在首次 SDK 响应时被确定 |
[n](n ≥ 1) | 首个哈希版本按 SDK 声明的该值生效 |
0 | 隐含的旧约定:TS SDK v1/v2 的"TS 专有"哈希方式(2023 年 1 月至 9 月期间使用) |
当前版本为1,步骤按以下格式哈希:fmt.Sprintf("%s:%d", stepID, idx)——即"步骤 ID : 步骤序号"的简单确定性拼接。相比 TS SDK 早期版本中"实现专有(implementation-specific)"的哈希,这种格式是可跨语言、跨平台复现的,正是 TS SDK v3 引入新哈希方式的动机:
We changed the way steps are hashed in v3 of the TS SDK, allowing cross-language, cross-platform live migrations of state.
换句话说,版本 1 的哈希格式是"语言无关"的:只要哈希输入确定,Go、TypeScript、Python 等任意 SDK 都能算出相同的步骤键,从而允许一个函数从 TS SDK 迁移到其他语言 SDK 时"原地迁移状态(live migrations of state)",而不必让历史步骤全部失效重跑。
2.1 版本 0 的历史包袱与纠正逻辑
README 特别强调:TS SDK v1/v2不支持哈希版本声明,这隐含了0版本对应 2023 年 1–9 月间 TS 专有哈希风格。执行器对这个历史值有专门的兼容处理——pkg/execution/executor/executor.go 中,若元数据的RequestVersion == 0,执行器会将其纠正为consts.RequestVersionUnknown(即-1)。源码注释解释了原因:SDK 侧根本没有"0"这个版本,如果第一次请求就把 0 发给 SDK,会触发错误的哈希路径;纠正为-1后,可以走"首次握手确定版本"的正常流程。
三、生命周期:从 -1 到确定版本的握手流程
README 的How it works一节描述了完整的版本生命周期,对照源码可以还原出清晰的三步流程:
3.1 新建函数:版本初始化为 -1
每当实例化一个新的函数执行(fn),哈希版本被置为-1(unknown)。对应代码在 executor.go:
// 新建元数据时 RequestVersion: consts.RequestVersionUnknown, if req.RequestVersion != nil { cfg.RequestVersion = *req.RequestVersion }3.2 首次 SDK 响应:确定并持久化版本
"第一条 SDK 请求应当携带哈希版本响应,该版本被写入 state metadata。"这条握手的落点在 executor.go 处理生成器响应的分支:
// NOTE: We only need to set hash versions when handling generator // responses, else the ... if i.md.Config.RequestVersion == -1 { // 将 resp.RequestVersion 写入元数据,并按 strictness 校验 RequestVersion: resp.RequestVersion, ... }即:只有当元数据版本仍为-1时,才用 SDK 响应中的版本去覆盖它——一旦版本被确定,后续响应不能改变已存储的版本,这保证了同一个函数运行(run)的全部步骤生命周期内哈希方式恒定。
3.3 后续执行:比较存储版本与 SDK 最新响应
README 指出:"当运行步骤时,我们把存储的哈希版本与 SDK 最新响应比较;如果版本变化,可以视严格程度选择告警(warn)或失败(fail)。"这是版本机制的"牙齿"所在:它不只是元数据,而是每次续跑都执行的一致性断言。若 SDK 升级导致哈希代际漂移(例如从版本 1 迁到版本 2),执行器能在重放开始前就发现,而不是让错位的步骤键悄悄破坏幂等。
四、x-inngest-req-version:SDK 兼容性的强制响应头
README 最后一段是面向所有 SDK 实现者的硬性协议要求:
All SDKsmustrespond with an
x-inngest-req-versionheader indicating the version used.
仓库中这个协议在三个层面被实现,形成闭环:
- 常量定义:pkg/headers/headers.go 定义
HeaderKeyRequestVersion = "x-inngest-req-version",并提供 headers.RequestVersion() 辅助函数,从响应头解析出 int 值。 - HTTP 驱动写入:经典 HTTP 驱动在构造出站请求时把元数据版本作为请求头发给 SDK——httpdriver.go 中
req.Header.Add(headerspkg.HeaderKeyRequestVersion, fmt.Sprintf("%d", *r.RequestVersion));常量headerRequestVersion = "x-inngest-req-version"定义在 httpdriver/util.go。 - HTTP 驱动解析响应:SDK 响应回来后,驱动从响应头读回版本并写入
SDKResponse.RequestVersion(httpdriver.go),供执行器做第 3.2 节的元数据更新。 - 新版 HTTPv2 驱动同样实现:httpv2/httpv2.go 发送请求头,响应解析处读取
headers.RequestVersion(resp.Header)。其测试用例(httpv2_test.go)还覆盖了头部值非纯数字等边界情况。 - 持久化往返测试:state_proto_test.go 验证
RequestVersion在 metadata ↔ protobuf 的往返序列化中不丢失。
从这套实现可以推断:版本信息走响应头而非响应体,是因为即使 SDK 返回 4xx/5xx 或响应体解析失败,执行器依然能拿到 SDK 的代际声明,握手协议的鲁棒性更高。
五、版本作为特性门控:force step plan 与 coalesce key
版本机制不仅是防御性的校验器,还是执行器新特性灰度的门控条件——SDK 必须先声明足够新的版本,执行器才会启用依赖新版请求语义的优化。仓库中有两处典型案例:
5.1 force step plan 要求版本 ≥ 2
pkg/execution/executor/force_step_plan.go 中:
if md.Config.RequestVersion < 2 || !md.Config.ForceStepPlan { return ... }只有当 SDK 声明的请求版本不低于 2 时,force step plan(配合SDKRequestContext.DisableImmediateExecution禁止 SDK 即时执行步骤的机制,见 request.go)才会生效。配套单测 force_step_plan_test.go 逐版本验证了门控行为。
5.2 按版本决定是否打 coalesce key
pkg/execution/executor/discovery_coalesce_test.go 中的TestHandleGeneratorResponse_CoalesceKeyBySDKRequestVersion展示了另一个版本敏感行为:
- SDK 请求版本为1时,响应省略coalesce key;
- 版本为2时,响应打上共享 coalesce key。
这印证了 README 的隐含逻辑:每个版本代际对应一组确定的请求/响应契约,新契约字段只在双方都声明了对应版本时才出现,从而避免旧 SDK 因无法理解新字段而误动作。
六、TS SDK v1–v3 的哈希演进与迁移含义
README 中关于 TS SDK 历史的一段值得单独展开,因为它解释了"为什么需要版本"的最初动因:
- v1 / v2(2023-01 至 2023-09 前后):步骤哈希采用"实现专有"方式,哈希逻辑绑定 TS 运行时细节,其他语言 SDK 无法复现同一哈希值。
- v3:改为语言无关的确定性格式,即当前版本 1 的
stepID:idx风格,使跨语言、跨平台的在途状态活迁移成为可能。
其工程含义是:Inngest 的状态后端里存着大量历史步骤键。哈希算法一旦变更,所有旧步骤键都将失配,函数要么整体重跑、要么彻底失败。版本机制让"哈希算法升级"变成一个可协商、可灰度的过程——老 SDK 继续按其声明的版本被服务,新 SDK 握手时声明新版本,执行器对同一 run 内版本漂移做 warn/fail 裁决(见 3.3 节)。0版本的存在与自动纠正(2.1 节)正是这段历史迁移在源码中留下的兼容痕迹。
七、验证路径:如何用测试与源码复核本文结论
若要在仓库内自行复核上述机制,建议按以下顺序阅读:
- 协议常量:pkg/headers/headers.go 与 pkg/consts/consts.go(
-1哨兵值); - 请求结构:pkg/execution/driver/request.go(
SDKRequest.Version)与 driver.go 的组装逻辑; - 握手生命周期:executor.go 初始化 → 版本 0 纠正 → 首次响应写入;
- 持久化:state_metadata.go 与 state_proto.go(含往返测试 state_proto_test.go);
- 版本门控:force_step_plan.go 与 discovery_coalesce_test.go。
结语
pkg/execution/state/README.md 虽篇幅不长,却刻画了 Inngest 执行引擎中一个高度关键的一致性协议:请求版本以-1 → 握手确定 → 逐次比对的生命周期,把"SDK 侧哈希/请求格式的代际"变成了一个可持久化、可比对、可门控的一等公民。它直接支撑了跨语言状态活迁移(版本 1 的语言无关哈希)、旧 SDK 兼容(版本 0 的自动纠正)与新特性灰度(版本 2 的 force step plan / coalesce key),是理解 Inngest 如何做到"服务器无状态、步骤执行 exactly-once"的关键拼图。
【免费下载链接】inngestThe leading workflow orchestration platform. Run stateful step functions and AI workflows on serverless, servers, or the edge.项目地址: https://gitcode.com/GitHub_Trending/in/inngest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考