Dex vs. 其他引擎
查看 MarkdownDex 和其他 durable execution 工具的目标相同:让流程在崩溃、重启和长时间等待之后继续运行。本页把每类工具的术语对应到 Dex,说明设计上的不同之处,以及何时其他工具更合适。
从 Temporal 过来? 按这个顺序读:
- 本页的主要区别和确定性要求转移到了哪里。
- Step,了解保证和默认值。
- Step 的进阶保证,了解重新投递、结果未知,以及 SYNC 和 ASYNC Step。
- 故障转移和部署,了解哪些会重新运行。
- 修改 Flow 代码,了解不依赖重放的版本管理。
- Attribute 锁,了解并行 Step 共享的状态。
- Client,了解怎样启动 Flow 和发送命令。
- 性能和限制。
共同点
本页的每种工具都在流程之外记录流程的进度。崩溃之后,流程从这份记录继续。这些引擎、运行时和库还都提供持久化 timer、等待外部事件和重试。Agent 框架的 checkpointer 覆盖的范围更小;它的小节说明它把哪些事留给你。
主要区别:显式的 Step 图取代重放代码
大多数 durable execution 工具让你把流程写成一个函数,再通过重放这个函数重建它的状态。Dex 则记录 Step 的结果。一个 Flow 是一组 Step。每个 Step 返回一个决定,指明接下来运行哪些 Step,或者完成 Flow,Dex 把结果和这个决定一起记录下来。
- 得到什么。 Step 代码就是普通代码。它可以读取时钟、使用随机数、调用任何库。修改代码永远不会让正在运行的 Flow 发生重放或非确定性错误。状态保存在 Attribute 里,而不是事件历史里。
- 代价是什么。 你要把控制流建模成 Step 和决定。循环是一个转到自己的 Step,分支是一个有两个目标的决定。
确定性要求转移到了哪里
一条规则取代了重放规则。Step 可能运行不止一次,所以每个外部效果只能依赖 Dex 传给这个 Step 的内容;见幂等键。本页的每种工具都把同样的责任留给你,故障证明也以此为前提。时钟和随机值可以用在 Step 的决定和保存的数据里,但不要用在幂等键里,也不要用在重新运行时必须完全一致的效果里。
并发的默认行为
Dex 在不同的 Worker 上并行运行同一个 Flow 的多个 Step,RPC 也可以和它们同时运行。它们共享 Flow 的 Attribute,由你选择哪些 Attribute 需要锁。Temporal workflow 在单线程上运行,Restate virtual object 每个 key 只允许一个独占 handler,所以两者默认都只有一个写入方。
同一个流程的两种写法
一个提醒循环每隔一段时间发送一次提醒,直到对方选择退出。在 Temporal 里,循环是由引擎重放的普通代码。下面这段代码只是示意,不来自 Dex 示例:
@workflow.defn
class ReminderWorkflow:
def __init__(self) -> None:
self.opted_out = False
@workflow.signal
def opt_out(self) -> None:
self.opted_out = True
@workflow.run
async def run(self) -> None:
while True:
try:
await workflow.wait_condition(
lambda: self.opted_out, timeout=REMINDER_INTERVAL
)
return
except asyncio.TimeoutError:
await workflow.execute_activity(
send_reminder, start_to_close_timeout=timedelta(seconds=30)
)
在 Dex 里,循环是一个转到自己的 Step。它等待 Timer 或退出 Channel,再由它的决定选择接下来做什么。下面的摘录来自可运行的 提醒 模式:
class ReminderStep(Step[None]):
def __init__(
self,
service: ServiceDependency,
opt_out: Channel[None],
) -> None:
self.service = service
self.opt_out = opt_out
def wait_for(self, context: Context, input: None) -> Wait:
return Wait.any_of(
Timer.by_duration(REMINDER_INTERVAL),
self.opt_out.for_one(),
)
def execute(self, context: Context, input: None) -> StepDecision:
if not context.has_timer_fired():
return graceful_complete()
self.service.send_email("Reminder: please respond", "Hello, ...")
return go_to(ReminderStep, None)
例子: examples/python/dex_examples/patterns/reminders/reminder_flow.py
type reminderStep struct {
dex.StepDefaults
service patternsservice.ServiceDependency
}
func (reminderStep) WaitFor(
ctx dex.Context,
_ dex.None,
) (*dex.Wait, error) {
return dex.AnyOf(
dex.Timer(5*time.Second),
OptOut.ForOne(),
), nil
}
func (step reminderStep) Execute(
ctx dex.Context,
_ dex.None,
) (*dex.StepDecision, error) {
if !ctx.HasTimerFired() {
return dex.GracefulComplete(nil), nil
}
step.service.SendEmail("Reminder: please respond", "Hello, ...")
return dex.GoTo(reminderStep{}, nil), nil
}
final class ReminderStep implements Step<Void> {
@Override
public Class<Void> getInputType() {
return Void.class;
}
@Override
public Wait waitFor(final Context context, final Void input) {
return Wait.anyOf(
Timer.byDuration(Duration.ofSeconds(5)),
optOut.forOne());
}
@Override
public StepDecision execute(final Context context, final Void input) {
if (!context.hasTimerFired()) {
return StepDecision.gracefulComplete();
}
service.sendEmail("Reminder: please respond", "Hello, ...");
return StepDecision.goTo(ReminderStep.class, null);
}
}
例子: examples/java/src/main/java/io/superdurable/dex/patterns/reminders/ReminderFlow.java
class ReminderStep implements Step<void> {
public constructor(private readonly service: ServiceDependency) {}
public getStepType(): string {
return "ReminderStep";
}
public waitFor(_context: Context, _input: void): Wait {
return Wait.anyOf(
Timer.byDuration(REMINDER_WAIT_MS),
optOut.forOne(),
);
}
public execute(context: Context, _input: void): StepDecision {
if (!context.hasTimerFired()) {
return gracefulComplete();
}
this.service.sendEmail("Reminder: please respond", "Hello, ...");
return goTo(ReminderStep, undefined);
}
}
例子: examples/typescript/src/patterns/reminders/reminder-flow.ts
#[derive(Default)]
struct ReminderStep {
service: MyDependencyService,
}
impl Step for ReminderStep {
type Input = ();
fn wait_for(&self, _context: &mut Context, _input: ()) -> HandlerResult<Wait> {
Ok(Wait::any_of([
Timer::by_duration(Duration::from_secs(5)),
OPT_OUT.for_one(),
]))
}
fn execute(&self, context: &mut Context, _input: ()) -> HandlerResult<StepDecision> {
if !context.has_any_timer_fired() {
return Ok(StepDecision::graceful_complete("opted-out".to_string()));
}
self.service
.send_email("user@example.com", "Reminder: please respond", "Hello, ...");
Ok(StepDecision::go_to(&ReminderStep::default(), ()))
}
}
提醒不带幂等键,因为多发一次提醒没有坏处。在 Rust 里,has_any_timer_fired 询问这个 Wait 中是否有任意一个 Timer 已触发。Temporal 版本从上往下读,靠重放重建。Dex 版本给每个状态命名,由 Dex 保存等待和决定。
如果你了解 iWF:Dex 由它发展而来。Dex 的 Step 就是 iWF 的 State,WaitFor 就是 WaitUntil。
基于重放的 workflow 引擎
Temporal 和 Cadence 是最知名的例子。Worker 根据记录的事件历史重放 workflow 函数,以重建它的状态。副作用放在 activity 里,workflow 代码只能通过确定性的 API 读取时间或随机数。
| Temporal 或 Cadence 术语 | 最接近的 Dex 术语 |
|---|---|
| Workflow type、Workflow ID、Run ID | Flow type、Flow ID、Run ID |
| Activity | Step Execute 里的工作,它是一个重试单元。每个持久化调用都放进自己的 Step。SYNC Step 要等两次对象存储提交;延迟见性能。 |
| activity 之间的 workflow 代码 | Step 的决定:GoTo、GoToMany、GracefulComplete、DeadEnd |
| Signal | Channel 消息。FlowService PublishToChannel 在没有 Worker 在线时也能保存它;见发布消息。 |
| Query | 只读 RPC,或者从 Client 直接读取 Attribute |
| Update | 写入 Attribute 或发布到 Channel 的 RPC。它需要在 10 秒内有 Worker 接手,并且没有按 update ID 去重。 |
| Timer 或 sleep | Step WaitFor 里的 Timer 条件 |
| Child workflow | SubFlow |
| Task queue 和 sticky queue | Flow type 队列,带尽力而为的 sticky 路由。要把工作交给单独的 Worker 池,就给它单独的 Flow type。 |
| Search attributes | 建了索引的 Attribute |
| Reset | Time Travel,保留同一个 Run ID |
| Continue-As-New | 用不到。Dex 为每个 run 保留一个最新历史事件的窗口,并清理更早的事件;见 History window(英文)。 |
| Local activity | 最接近的是 ASYNC Step,它在结果保存之前就继续往下走,如果 server 节点先出故障,它会重新运行。 |
| Cancellation scope | RPC 按 Step 类型取消正在运行的 Step,StopFlow 停止整个 Flow。取消是协作式的;见取消正在运行的 Step。 |
| Schedule | 一个在 Timer 上循环的 Flow;见 Cron 定时调度。 |
相同之处。 每个 Flow ID 只有一个活跃的 run,并有 ID 复用策略。两者都使用 pull 模式的 Worker;持久化 timer 和等待都不占用 Worker;每个工作单元有自己的重试策略。
区别。
- Worker 读取保存的状态,而不是重放历史,所以它用不到历史缓存。见 Routing and availability(英文)。
- 版本管理靠保留旧的 Step 类型并做增量修改,不需要 patch 标记。修改 Flow 代码说明规则,部署前检查给出手动兼容性检查。
- Dex 的 Step 在不同的 Worker 上并行运行。Temporal 的单线程仍然会在 await 点交错运行 handler,所以差距比“一个写入方”听起来要小,但差距确实存在。
- Step 方法的重试复用第一次尝试的状态快照。activity 的重试会读取最新数据。
- 重试的 RPC 或重复的 Channel 发布会再次运行;Temporal 会丢弃重复的 update,并尽力丢弃重复的 signal。见限制。
- Client 根据在自己语言里注册的 Flow 定义来启动 Flow。其他语言的服务通过 gRPC 调用 FlowService
StartFlow;见从 API 服务启动 Flow。 - server 故障转移会重新运行每个进行中的 Step 尝试。见故障转移和部署。
- Dex Server 是一个运行在对象存储上的二进制文件,有一个 active 节点和若干 standby。Temporal 由多个服务组成,运行在数据库上。
基于日志的 durable function 运行时
Restate 是一个例子。handler 通过运行时 context 执行每个副作用,运行时把结果记录在日志(journal)里。失败之后,handler 再次运行,已记录的结果直接返回。带 key 的 virtual object 保存状态,每个 key 只有一个写入方。
| Restate 术语 | 最接近的 Dex 术语 |
|---|---|
| Workflow | Flow |
| Service handler | 一个没有 Attribute 的短 Flow。Service handler 是一次持久化、记录了日志的调用,有重试和幂等键,但没有 key-value 状态。 |
记录日志的操作(ctx.run) | Step Execute |
| Virtual object 及其 key | 每个 key 一个长期运行的 Flow,用 key 作为 Flow ID。已关闭的 run 在保留期后被删除,而 virtual object 会一直存在。 |
| Object state | Attribute |
| Awakeable 或 durable promise | Step 等待的 Channel 消息 |
| Durable sleep | Timer |
| Shared handler | 不写入 Attribute 的 RPC |
相同之处。 崩溃后复用已记录的副作用结果。状态按你选择的 ID 分 key 保存。
区别。
- 日志调用之间的 handler 代码必须是确定性的,因为它会再次运行。Dex 每次都重新运行 Step 代码,所以修改代码不会破坏正在运行的 Flow。
- virtual object 每个 key 只允许一个写入方。同一个 Flow 的 Step 和 RPC 可以同时运行,Attribute 锁按需启用。
- Restate 按幂等键对调用去重。Dex 按 Flow ID 和 request ID 对 Flow 启动去重,目前重复的 Channel 发布和 RPC 会再次运行。
- Restate 的 UI 和 CLI 显示一次调用的日志。Dex 把每个 Step 的输入、输出和错误随 run 一起保存到保留期结束,Dex Web 在 Flow 的图和 Timeline 上按 Step 显示它们。
基于数据库的 workflow 库
DBOS 是一个例子。workflow 和 step 是你应用里加了注解的函数,checkpoint 保存在你的 PostgreSQL 数据库里。崩溃之后,workflow 再次运行,已完成的 step 返回记录下来的输出。
| DBOS 术语 | 最接近的 Dex 术语 |
|---|---|
| Workflow、Workflow ID | Flow、Flow ID |
| Step | Step Execute |
| Send 和 receive | Channel |
| Events(set 和 get) | Attribute,通过 RPC 或 Client 读取 |
| Durable sleep | Timer |
| Queue | Flow type 队列。它没有按队列设置的并发或速率限制;每个 Worker 的任务槽位限制它的工作量。 |
相同之处。 普通的应用代码,加上记录下来的 step。Workflow ID 让启动具有幂等性。
区别。
- DBOS 的 workflow 函数在 step 之间必须是确定性的。
- DBOS 的 send 接受幂等键。目前 Dex 重复的 Channel 发布可能投递两次。
- DBOS 只用你自己运行的 PostgreSQL 数据库,不增加其他 server。Dex 在对象存储上运行自己的 server,并且可以把 Attribute 值同步到你的数据库。
Step function 服务
AWS Step Functions 是一个例子:用 JSON 定义语言写的状态机,每个状态调用一个服务。两者都记录一张显式的状态图。Dex 的 Step 和决定是用你的语言写的代码,运行在你的 Worker 上,所以 Step 可以调用任何库。
Agent 框架 checkpoint
LangGraph 是一个例子。checkpointer 在每一步之后按 thread ID 保存图的状态,interrupt 让图暂停,等待人来处理。它的托管平台负责恢复 run。
| Agent 框架术语 | 最接近的 Dex 术语 |
|---|---|
| Thread ID | Session ID,也就是 Flow ID |
| Checkpoint | 每次模型调用或 Tool Call 之后 Session 的 Attribute |
| 等待人工输入的 interrupt | 在 AwaitToolApproval 中等待的 Tool Call,或者一个提问 |
| Node 或 Tool Call | 一个 Step |
checkpointer 负责保存状态;重启崩溃的 run,或者用 timer 唤醒它,需要托管平台或你自己的代码。Dex Server 为每个 Session 做这些事。使用 Dex Agents 时,你继续用自己的框架,由适配器把 Agent 包装进一个 Session Flow;见 持久化 Agent。
运行在 durable execution 引擎上的 Agent 框架
运行在 Temporal 上的 OpenAI Agents SDK、运行在 Temporal、DBOS 或 Prefect 上的 Pydantic AI,以及 LangGraph 平台,都把每次模型调用和包装过的 Tool Call 记录为引擎的一个单元,所以崩溃只会重复正在运行的那次调用。
- 在 Dex 中,每个 tool 都有一个 effect class。读取可以再次运行。非幂等写操作默认只尝试一次,如果被中断,会为模型记录
outcome_unknown;见 Tool Call。 - effect class 也决定默认的审批方式。审批等待不占用 Worker,可以持续几分钟或几天;见 审批。
- 一个适配器模型和一个 Session Flow 覆盖所有受支持的框架。
- Dex Server 的版本是 0.1,可以运行在 Dex Cloud 上,也可以运行在你自己的基础设施上。上面这些引擎有更长的生产使用记录。
何时其他工具更合适
- 你想把流程写成一个从头到尾顺序执行的函数。 基于重放的引擎、基于日志的运行时或基于数据库的库都支持这种写法。Dex 要求你把流程建模成 Step 和决定。
- 你现在就需要 1.0 版本的 server。 Dex Server 的版本是 0.1。其他几种工具有更长的生产使用记录。
- 你的流程是一个服务里的一个短函数,而你已经在运行 PostgreSQL。 基于数据库的库不会增加新的 server。
- 你的团队已经在大规模运行另一个引擎。 迁移的代价会超过本页这些差异带来的好处,除非重放或版本管理错误对你来说确实是个问题。
- 你希望每个 key 只有一个写入方,而且不用声明锁。 virtual object 或单线程的 workflow 默认就是这样。在 Dex 里,你要声明 Attribute 锁。
- 你需要一个集群提供超过一个节点的容量。 一个集群由一个 active Dex Server 节点服务。超出这个容量时,你要运行多个独立的集群。见超出一个节点的容量。
- 你需要 OSI 认可的开源许可证。 Dex 以 Sustainable Use License 提供源代码,这个许可证限制把 Dex 作为平台托管或转售。Temporal 和 DBOS 使用 MIT 许可证。见许可证。
下一步
- 性能:实测吞吐、延迟和成本。
- Dex vs. 数据库:用数据库和队列搭建的同一个订单流程。
- Step 的保证:Step 失败或再次运行时 Dex 承诺什么。
- 正确性与表达能力:证明及其前提假设。