TypeScript 的持久协程
Durable Coroutines for TypeScript

原始链接: https://restate.dev/blog/durable_coroutines

Restate 的 TypeScript SDK 现已包含持久协程:具有工作流式恢复能力的持久、并发、多步骤例程,但每个持久动作的开销仅毫秒级(在复制的 3-AZ GCP 设置中,>10,000 actions/sec 时 P50=2.5ms,P99=9ms)。它们与持久 Agent 构建者共同开发,而基于每步 activity 的调度难以进行细粒度进度跟踪。协程提供类似持久进程的模型,其中步骤可以使用局部变量和缓存资源。 API 使用 TypeScript 生成器作为确定性协程:`run()` 创建持久步骤,`spawn()` 启动并发任务,`select()`/`all()` 组合它们。调度器一次推进一个 fiber,使用 `RestatePromise.race()` 记录完成顺序,使重放确定性。内存中的 future 可能同步完成;Restate 动作会被记录。取消会通过生成器抛出终止错误,以便清理。 持久协程补充了 Restate 的持久函数:细粒度子步骤使用协程,更重的排队工作使用单独函数。它们已为 Replit Agent 等生产系统提供支持,并可通过 Restate SDK 获取。

Hacker News 新 | 过去 | 评论 | 提问 | 展示 | 工作 | 提交 登录 TypeScript 持久协程 (restate.dev) 10 分 由 calvinfo 1小时前 | 隐藏 | 过去 | 收藏 | 1 条评论 帮助 Muromec 16分钟前 [–] 当我编写我的测试框架时,我不惜代价把生成器封装成一个真正的类型化 actor 系统,而且映射得非常好。 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

We added durable coroutines to Restate's TypeScript SDK: persistent, concurrent, multi-step routines that provide the same persistence and recovery semantics as workflows, but add only milliseconds of overhead per durable action, instead of the 50-100 milliseconds often experienced in workflow solutions1. In production deployments, a durable step in a coroutine adds a latency overhead of P50=2.5ms, P99=9ms at rates of >10,000 durable_actions/second for a synchronously replicated 3-AZ setup on GCP.

We developed Durable Coroutines together with users building durable agents. Modern agent harnesses persist and track their progress in fine-grained steps: chunks of LLM results, multiple steps per tool call (intent, guardrail, result chunks). That granularity gives agents a stronger model when recovering from failures. But this is hard to achieve with a durable execution model that schedules and dispatches activities to workers for each persistent step. We built durable coroutines to fill that gap. An added perk of this approach is that the code can assume a durable process-like model, where durable steps can rely on local variables and make efficient use of cached resources.

The coroutines implementation makes heavy use of TypeScript's generator functions to implement a deterministic scheduler controlling the execution.

Background: Workflows vs. Durable Functions

Durable execution originally came in the shape of workflows and activities: if you want a persistent step, you need to create an activity, which gets scheduled on some worker. In systems like Temporal, that means the workflow task cedes execution (returns to the workflow orchestrator), the activity is sent to a worker to execute and return a result, and the workflow task gets executed again2. Parallel work means setting up multiple activities, and when the workflow task cedes execution, all activities start running. This approach fits a task-queue & worker architecture.

agent.workflow.tswaiting to be scheduled

1export async function agent(prompt: string) {2  const context: Message[] = [userMessage(prompt)];3 4  while (true) {5    const next = await callModel(context);6    if (next.done) return next.answer;7 8    const calls = next.tools.map((t) =>9      callTool(t));10    const results = await Promise.all(calls);11    context.push(...results);12  }13}
Workflow Serverholds the workflow history and task queuesagent()Worker 1idleWorker 2idleWorker 3idle

workflow suspended — nothing is running

agent.tsnot invoked yet

1export function* agent(prompt: string) {2  const msgs: Message[] = [userMessage(prompt)];3 4  while (true) {5    const next = yield* run(() => callModel(msgs));6    if (next.done) return next.answer;7 8    const calls = next.tools.map((t) =>9      spawn(callTool(t)));10    const results = yield* all(calls);11    msgs.push(...results);12  }13}
bidi streamRestate Serverpushes the invocation · journals every stepjournal0 entriesService processlong-lived · one event loopidle — no invocation

process idle — waiting for an invocation

An alternative approach that Restate and other systems implemented is often described as Durable Functions. The functions can have durable steps (ctx.run(...), plus durable signals, sleeps, promises, ...) and compose via RPC (calling other durable functions). Durable steps are faster than activities (or calling other durable functions) because they need to do less work: Restate maintains a streaming connection to the executing functions, sending steps and acks between the application and Restate Server. Restate's entire architecture is designed to make step commits fast.

Durable functions give developers the choice between two models: putting a unit of work (that should be persisted) into separate functions or activities is useful for heavier tasks, or when the work needs to be queued and dispatched to different workers/deployments and run with specific rate or concurrency limits.

In contrast, programs written with durable steps & coroutines feel more like writing durable processes. Except in actual failure/recovery cases, the execution is one continuous run in the same process, letting the code reuse local resources like cached files/results or authenticated connections. Restate has always supported durable steps, but the addition of Durable Coroutines fixes the previously limited support for concurrency in that API.

Both models complement each other: We often see developers put larger work (like conversation turns or long-running tools) into a separate durable function (whose invocation has queueing and flow control) and use steps/coroutines to capture the durability of substeps.

Enter Durable Coroutines

Restate's Durable Coroutines library takes the idea of coroutines from programming languages like Go, Rust, or Kotlin and implements a deterministic scheduler to make them compatible with journal-based durable execution.

There are only a few important concepts:

  • Everything is a generator function because that allows Restate to tightly control the execution (see How it works). If you've never worked with generator functions, you can squint your eyes and pretend that yield* fulfills a similar purpose here as await does for Promises.
  • run(...) is a single durable step, like in Restate's standard API.
  • spawn(...) creates a new concurrently executed coroutine (Task), which can have its own sequence of durable actions and can spawn further Tasks.
  • select(), all(), and other combinators combine steps and tasks in the same way as promise/future combinators do.

Let's dive into an example: an agent loop that spawns tools as concurrent sub-workflows and listens for steering signals.

function* agent(userMessage: string): restate.Operation<string> {
  const sandbox = yield* connect(); // re-usable connection across tool calls
  const messages: Message[] = [{role: "user", content: userMessage}];

  const tasks = new Map<string, restate.Task<ToolResult>>(); // tasks in-flight
  let steer = restate.signal<string>("steer"); // to wait for a note from the user

  // Durable agent loop
  while (true) {
    // 1. Durable LLM call
    const action = yield* restate.run(async () => llm(messages));
    if (action.type === "final") {
      return action.message;
    }
    messages.push({role: "assistant", calls: action.calls});

    // 2. Spawn parallel tool calls (each of them possibly a multi-step sequence)
    for (const call of action.calls) {
      tasks.set(call.id, restate.spawn(performCall(call, sandbox)));
    }
    messages.push({
      role: "tool",
      results: action.calls.map(({id}) => ({id, result: "task created"})),
    });

    // 3. Race tool calls with new steering input
    const next = yield* restate.select({...Object.fromEntries(tasks), steer});
    if (next.tag === "steer") {
      // process steering note
      messages.push({role: "user", content: yield* steer});
      steer = restate.signal<string>("steer"); // renew the signal
    } else {
      // process completed tool call
      const result = yield* tasks.get(next.tag)!;
      tasks.delete(next.tag);
      messages.push({role: "tool", results: [result]});
    }
  }
}

To see how far you can go with Durable Coroutines, have a look at Restate's agent reference architecture, which uses them to compose steering, interruption, sync/async tools, programmatic tool calling, and more.

A coding agent concurrently executes LLM calls, subagents, tools, approvals, and steering commands in a single durable concurrent process. Every step is durable, replay-stable, and recoverable.

How it works

The library uses TypeScript generators instead of Promises (async/await) to give the SDK control over coroutine execution. With async/await, the event loop decides how the execution of different concurrent code paths interleaves. Generators instead pause at each yield until the caller explicitly resumes them. This lets the SDK decide which coroutine continues next and what result it receives. By recording the order of completion in the journal, the SDK can replay execution deterministically.

Take a tool with two durable steps: a guardrail and the actual tool execution. For each step, yield* pauses the coroutine until the result is there:

function* runTool(tool, params) {
  const allowed = yield* run(() => guardrail(tool, params)); // true/false
  if (!allowed) {
      return "Tool call rejected by guardrail";
  }
  return yield* run(() => callTool(tool, params));
}

// SDK drives execution via:
const task = runTool(tool, params); // runs nothing yet
task.next(); // starts guardrail step and pauses
task.next(true); // guardrail passed: starts tool call step and pauses
task.next(toolResult); // returns

run() doesn't return its result directly. Like all actions (a call to another service, a timer, or a signal), it returns a Future. yield* hands that Future to the SDK (the scheduler) and pauses the coroutine. The scheduler waits for the Future to complete, then calls next() with the result, which becomes the value of the yield* expression.

Concurrency

The code may start additional concurrent coroutines (Tasks) via spawn(...). The scheduler advances all coroutines (repeatedly calls next(...) across yield points) until each coroutine has yielded a future to a Restate action (e.g., a run(...) step, a signal, RPC, ...), meaning everyone is currently waiting.

This is the critical point where we need to ensure that we record the order in which these actions complete, so we can deterministically replay them on recovery. The scheduler does that by running RestatePromise.race(pendingActions), which automatically records the winning completion in the durable execution journal. It then resumes the waiting coroutine and starts advancing coroutines again. Exactly one coroutine executes at a time, but the I/O in their durable steps overlaps.

To illustrate this, here is the execution of an agent running two concurrent tools, each consisting of a guardrail step and a tool call step. Note how the SDK runs race(...) each time all coroutines are waiting on a durable step (run(...)).

Service process

Restate SDKinvokeagent(ticket)
function* agent(ticket) { const plan = yield* run(() => llm(ticket)); const crm = spawn(crmTool(plan.user)); const billing = spawn(billingTool(plan.user)); const [a, b] = yield* all([crm, billing]); return summarize(a, b);}
The SDK controls which fiber can make progress by calling next(). The order of completion is recorded in the journal, for deterministic recovery.

Inside the scheduler

The scheduler wraps every coroutine in a fiber: the generator plus a state that is ready, waiting, or done. Ready fibers sit in a FIFO queue. The scheduler advances a fiber by calling next() on its generator, which runs the coroutine's code synchronously up to its next yield*. Along the way, the code can spawn additional coroutines (Tasks):

  • run() creates the durable step and returns its Future without awaiting it
  • spawn() creates a fiber and appends it to the ready queue. The new coroutine starts only once the current routine stops running.

Let's zoom in on how the scheduler moves each coroutine forward until it waits on a Restate action. Here is a simplified version, based on scheduler.ts and fiber.ts:

const fibers = new Set(); // all live coroutines
const ready = []; // fibers that can run

async function execute(handler) {
  const main = spawn(handler);
  drain();
  while (!main.isDone()) {
    // Steps are journaled in the background in the order they complete.
    // Race(...) waits for the first completion to appear in the journal.
    // Replays reproduce the race deterministically from the journal.
    const { fiber, result } = await RestatePromise.race(pendingActions());
    wake(fiber, result);
    drain();
  }
  return main.result;
}

// The Restate actions that fibers wait on
function pendingActions() {
  return [...fibers]
    .filter((fiber) => fiber.waitingOn?.isRestateAction)
    .map((fiber) =>
      fiber.waitingOn.promise.map((result) => ({ fiber, result })),
    );
}

// Advance fibers one at a time until all of them are waiting
function drain() {
  while (ready.length > 0) advance(ready.shift());
}

// Run one coroutine until it waits on something unfinished
function advance(fiber) {
  let input = fiber.input; // result of what it last waited on
  while (true) {
    const step = fiber.generator.next(input); // runs user code
    if (step.done) {
      return finish(fiber, step.value);
    }
    const future = step.value; // the Future passed to yield*
    if (future.isRestateAction) { // durable action: set up for next RestatePromise.race()
      fiber.waitingOn = future;
      return;
    }
    if (future.isDone()) {
      input = future.result; // in-memory and completed: fall through loop
      continue;
    }
    fiber.waitingOn = future; // in-memory and pending, put aside
    future.onDone((result) => wake(fiber, result));
    return;
  }
}

function wake(fiber, result) {
  fiber.waitingOn = null;
  fiber.input = result;
  ready.push(fiber);
}

function finish(fiber, result) {
  fibers.delete(fiber);
  fiber.task.complete(result); // calls the onDone callbacks
}

function spawn(coroutine) {
  const fiber = new Fiber(coroutine);
  fibers.add(fiber);
  ready.push(fiber); // starts once the current coroutine stops
  return fiber.task;
}

function run(action) {
  // Creates the durable step, but journals only once the scheduler awaits
  // the race. Directly supplies result if action is already complete (replay).
  return restateActionFuture(ctx.run(action));
}

At a yield*, the yielded value tells the scheduler which Future the coroutine is waiting for. Because yield* delegates through nested generator calls, even a run() nested three generator functions deep surfaces the next time the fiber calls next(). Futures represent either a Restate action or an in-memory result (a Task or a channel to another coroutine). There are basically four cases to consider:

  • A Restate action (a step, timer, call, or signal): the fiber records it as the action it waits on, and the scheduler moves on to the next ready fiber. These actions go into the next RestatePromise.race(...) call.
  • An in-memory Future that is already done (a finished Task, or a channel that holds its value): the scheduler calls next() again with the result and the coroutine keeps running.
  • An in-memory Future that is still pending: the fiber registers to be woken when it completes, then waits.
  • The end of the function: the fiber is done. The task moves every other fiber waiting on it back into the ready queue.

Cancellation

The use of generators also gives the SDK a natural way to cancel coroutines. When a cancellation signal arrives, the SDK calls throw(TerminalError) on each coroutine instead of next(). The TerminalError is thrown at the yield* in the coroutine, and gives it a chance to run any cleanup logic in the catch or finally blocks. In-flight run closures get an aborted AbortSignal.

To cancel a coroutine spawned by you, you can call task.interrupt(error) on the Task returned from spawn. This throws a terminal error at its next yield* that cascades to the coroutines it spawned.

Let us know what you think!

Durable Coroutines are already running in production in various companies. For example, Replit Agent is built on durable coroutines. If you like this, give it a spin and let us know what you think.

Restate is open, free, and available on GitHub. To learn more about Durable Coroutines, install the dependency: npm install @restatedev/restate-sdk @restatedev/restate-sdk-gen or get started with the template npx @restatedev/restate example typescript-hello-world-gen, and check the README for a quick tour.

If you like what we are doing, give us a star on GitHub, and join the community on Discord or Slack.

联系我们 contact @ memedata.com