Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

RFC 003: Durable Workflow Start

未关闭
#36 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
10/100
Issue 类型
功能
描述清晰度
基本清楚
活跃度
活跃
技术栈
typescript

调研方向

This is a design RFC, not a bug to fix. Start with the Proposed API and Runtime API sections, which define step.start, WorkflowStartClient, durableValueCodec and WorkflowStartError in @yieldstar/core, and read the cross-referenced RFCs 001, 002 and 007 to 013. Done means the design is accepted and the conformance tables are covered by tests, which is a maintainer-led discussion rather than a first contribution.

由索引模型根据 Issue 内容生成。

描述

enhancement

Abstract

When a workflow triggers another workflow, it is possible – in rare circumstances – for it to be triggered twice. This occurs when a worker crashes after the inner workflow has been created, but before the durable step records completion. In that case, the step is replayed and the inner workflow is triggered again.

To solve this problem, we will introduce step.start specifically for starting another workflow as a durable step. If the step is replayed, it will return the original target execution instead of triggering the workflow again.

https://github.com/user-attachments/assets/3ec59490-769c-45b2-9d95-0cd144019acd

Video transcript

Take a shop that uses Yieldstar to run its orders. When order 42 comes in, the shop starts an execution of a workflow called charge-order, with the execution ID order-42. The workflow charges the card. Then it hands the delivery to a second workflow, called ship-order, which books a courier. Ship-order runs as its own execution, with its own ID, so it can take days without holding up charge-order.

Yieldstar saves the result of each step under the execution ID. If a worker crashes, Yieldstar runs the workflow again from the top. Keep this rule in mind, because everything that follows hangs on it: a step with a saved result is skipped, and a step without one runs again. Yieldstar has no step for starting another workflow. So the shop wraps a call to trigger in an ordinary step, called ship. Trigger creates the ship-order execution and returns its ID, and Yieldstar saves that ID as the ship step's result.

Problem: the courier comes twice. Inside the ship step, two writes happen one after the other. First, trigger creates the ship-order execution. Second, Yieldstar saves the ship step's result. Say the worker crashes between those two writes. The ship-order execution exists, and it is already booking a courier. But the ship step has no saved result. So when Yieldstar runs charge-order again, by the rule, the ship step runs again. Trigger has no way to know that it was called before, so it creates a second ship-order execution, with a new ID. Two couriers turn up for one parcel.

And it can go wrong again and again. Every crash in that gap adds another ship-order. It doesn't even take a crash. Remember RFC 002: a worker that freezes loses its lease, and a second worker replays the workflow. If the first worker froze inside the ship step, the second worker runs the ship step again, and when the first worker wakes up, its trigger goes through too. This is the heart of the problem. The crash is not the cause. The cause is that trigger forgets. Every call to trigger looks like a brand-new request, so nothing can tell a repeat from a first call.

RFC 003 will add a step for starting another workflow, called step dot start. The shop will give it a step key, ship, and the workflow and params it wants to start. Step dot start will return the ID of the ship-order execution. And unlike trigger, step dot start will remember that it was called.

This is how step dot start will remember. Each start will carry a request ID with two parts: the caller's execution ID, order-42, and the step key, ship. Together they name one step in one execution. A replay of order-42 reaches the same step, so it sends the same request ID. Yieldstar will keep each request ID next to the execution it started. The first time a request ID arrives, Yieldstar will create the ship-order execution. When the same request ID arrives again, Yieldstar will find it and return the first execution's ID instead of creating another.

This only works if Yieldstar saves the request ID and the new execution together. Remember the gap: two writes, one after the other, with a crash in between. If Yieldstar created ship-order and then crashed before it saved the request ID, a replay would find no request and start a second ship-order. So a new start will be all-or-nothing. Either the ship-order execution and its request ID are saved together, or neither is saved and a replay starts afresh. The whole RFC rests on this guarantee: there will never be a moment when the execution exists but the record of the request does not.

Now replay the crash from earlier. The worker calls step dot start, Yieldstar creates ship-order, and the worker crashes before it saves the ship step. Charge-order runs again, and by the rule, the ship step runs again. But this time the ship step sends the same request ID: order-42 and ship. Yieldstar finds the request and returns the ID of the ship-order that already exists. One courier. The frozen worker from RFC 002 gets the same answer: when it wakes up, its start finds the request and starts nothing new.

One question remains. What if the replay asks for something different? Say that while order-42 was running, the shop deployed new code, and the ship step now starts a workflow called ship-express. The replay sends the same request ID with a different request. If Yieldstar returned the old ship-order, it would hide the change. If it started ship-express, the second courier would be back. So Yieldstar will do neither. It will refuse the start with a mismatch error (START_REQUEST_MISMATCH), and step dot start will throw that error in the workflow, so the shop sees the conflict.

To spot a mismatch, Yieldstar has to compare the params, and two params that mean the same thing must match, even if their keys are in a different order. So step dot start will only accept plain data: strings, numbers, booleans, null, and arrays and objects built from them. Yieldstar will turn the params into one fixed form of JSON, with the keys sorted, and compare those strings. A date, a function or undefined has no single JSON form, so step dot start will refuse it (INVALID_START_PARAMETERS) before it starts anything.

Before, starting one workflow from another is safe only if no worker crashes or freezes between the trigger and the save. With RFC 003, each start will belong to one step in one execution. However many times the ship step runs, ship-order will start once, and one courier will turn up. Ship-order's record will also note who started it, order-42 and its ship step. RFC 008 will use that link to let charge-order wait for the delivery to finish.

Problem

Yieldstar records each completed step so that a workflow can resume after a crash.

Yieldstar has no step for starting another workflow. Authors must call trigger inside step.run.

yield* step.run("start:job:job-1", async () => {
  const promisedTarget = sdk.trigger({
    workflowId: "process-job",
    params: { jobId: "job-1" },
  })

  // If the worker crashes here, replay will trigger the workflow again.

  return promisedTarget
})

Proposed solution

step.start will start another workflow as a durable step. Replaying it with the same step key will return the first target instead of creating another.

// Replaying this step will return the same target execution ID.
const target = yield* step.start("start:job:job-1", {
  workflowId: "process-job",
  params: { jobId: "job-1" },
})

Proposed API

step.start will take a step key and a target workflow. It will return { executionId } for the target it creates or finds.

type DurableValue =
  | null
  | boolean
  | number
  | string
  | DurableValue[]
  | DurableObject

type DurableObject = { [key: string]: DurableValue }

type DurableValueEncoding =
  | { status: "encoded"; canonical: string }
  | { status: "invalid"; message: string }

interface DurableValueCodec {
  encode(value: unknown): DurableValueEncoding
}

declare const durableValueCodec: DurableValueCodec

interface StepRunner {
  start(
    stepKey: string,
    target: {
      workflowId: string
      params?: DurableObject
    },
    options?: { detached?: boolean }
  ): AsyncGenerator<
    StepResponse,
    { executionId: string },
    StepResult | StepError
  >
}

Parameters will accept:

  • null;
  • booleans;
  • finite numbers;
  • strings;
  • arrays with no missing items; and
  • plain objects whose values follow these same rules.

A plain object will:

  • have the default object prototype or null; and
  • contain only its own enumerable data properties with string keys.

Parameters will reject:

  • accessors;
  • symbol or non-enumerable properties;
  • missing array items;
  • undefined;
  • functions;
  • symbols;
  • reference cycles.

@yieldstar/core will export the durableValueCodec value. Its encode operation will validate recursively and return either an error message or one canonical JSON string. It will sort object keys and encode -0 as 0. RFC 011 adds decode and makes this codec the encoding for every durable value.

detached is defined by RFC 009. A detached target will not receive its caller's cancellation.

Runtime API

This RFC will add these types to @yieldstar/core:

interface WorkflowStartClient {
  start(request: {
    requestId: {
      executionId: string
      stepKey: string
    }
    workflowId: string
    params?: DurableObject
    detached?: boolean
  }): Promise<
    | { status: "accepted"; executionId: string }
    | {
        status: "rejected"
        code: WorkflowStartErrorCode
        message: string
      }
  >
}

type WorkflowStartErrorCode =
  | "INVALID_START_PARAMETERS"
  | "START_REQUEST_MISMATCH"

declare class WorkflowStartError extends Error {
  constructor(
    readonly code: WorkflowStartErrorCode,
    message: string
  )
}

Execution admission, ExecutionClient and ExecutionAdmissionError are defined by RFC 001.

Required Semantics

  • A start request will be identified by the caller's execution ID and the step key.
  • A repeated request with the same workflow ID and parameters will return the first target execution ID without starting another workflow.
  • A repeated request with a different workflow ID or parameters will return START_REQUEST_MISMATCH, also after the target has reached a terminal outcome.
  • Parameter values will match when their canonical encodings match. Missing parameters and an empty object will not match.
  • WorkflowStartClient will validate parameters with durableValueCodec and will return INVALID_START_PARAMETERS when encoding fails, without admitting an execution.
  • A successful new start will be all-or-nothing. Either the target is admitted through RFC 001, with its parent link and its first wake, or no trace of the request is observable.
  • The target's execution ID will follow RFC 001's uniqueness rules. Two start requests will never receive the same execution ID, and a started execution will never collide with a directly triggered one.
  • The target's record will hold the parent link { executionId, stepKey, detached } from RFC 008 and RFC 009.
  • An accepted start will be recorded as a durable step. Replay will return the recorded { executionId } without another client call.
  • A rejected start will be recorded and thrown to workflow code as WorkflowStartError. Replay will restore the same class and code without another client call.
  • If WorkflowStartClient throws, no step will be recorded and workflow code will not be able to catch the error. The next execution attempt will call the client again with the same request ID.
  • start will be idempotent by its request ID and will not be fenced (RFC 002). A stale lease holder that calls it receives the same target and starts nothing new.
  • A caller's start-request records will be pruned with the caller (RFC 013). The caller is terminal by then, so it will never replay them.

step.call from RFC 008 will reuse this request identity and these mismatch rules.

Delivery acknowledgement

A delivery will end in one of these outcomes:

type WorkflowRunOutcome =
  | { status: "completed"; result: unknown }
  | { status: "suspended" }
  | { status: "failed"; error: Error }
  | { status: "cancelled" }

interface WorkflowInvoker {
  // Deprecated by RFC 007. No guarantee depends on it.
  workflowEndEmitter: EventEmitter
  deliver(event: ExecutionEvent): Promise<WorkflowRunOutcome>
}

Each WorkflowRunOutcome will mean that the matching durable work is finished:

  • completed will mean that the workflow returned a result;
  • suspended will mean that the unfinished step and every wake needed to resume it are recorded;
  • failed will mean that workflow code threw an uncaught error; and
  • cancelled will mean that workflow code ended by throwing WorkflowCancelledError after a recorded cancellation request (RFC 009).

The dispatcher will record the outcome with ExecutionClient.acknowledge under the delivery's lease (RFC 001 and RFC 002). RFC 001 defines where terminal outcomes are stored, and that a later delivery of a terminal execution returns the recorded outcome without running workflow code.

An error thrown by infrastructure, rather than by workflow code, will not be an outcome. deliver will reject, the worker will reply retry, and the dispatcher will release the lease. RFC 002's redelivery delay will then apply. A child error or an exit before an outcome will have the same meaning.

Only the dispatcher will deliver an execution. A direct trigger will admit the execution through RFC 001 and resolve once admission commits, without starting a worker.

triggerAndWait and the HTTP observation routes will read the execution record (RFC 007). A runtime may keep emitting on workflowEndEmitter as an in-process fast path.

@yieldstar/worker-invoker and @yieldstar/test-invoker will implement the same deliver contract.

Conformance tests

Runtime-adapter tests will exercise WorkflowStartClient. Admission cases are in RFC 001.

Case Observable outcome
Failure before a new start commits No target is admitted; a repeated call succeeds as a first call.
Start commit followed by a lost response A repeated call returns the committed execution ID; exactly one target is admitted.
Two concurrent calls with one start-request ID Both return the same execution ID; exactly one target is admitted.
Two different start-request IDs Each receives a different execution ID.
A new start commits The target is admitted, due, and has the caller as its parent.
A start with detached: true The target's parent link is marked detached.
Invalid start parameters The client returns INVALID_START_PARAMETERS; no execution is admitted.
Parameter objects have different key order durableValueCodec returns the same canonical string and the requests match.
A repeated request changes the workflow ID or parameters The client returns START_REQUEST_MISMATCH; the original start is unchanged.
A matching request repeats after the target is terminal It returns the original execution ID without admitting another target.

Workflow-driver tests will inspect recorded steps and calls to a fake WorkflowStartClient:

Client behavior or interruption Observable outcome
Returns accepted The execution ID object is recorded and returned; replay makes no client call.
Returns rejected A WorkflowStartError and its code are recorded and thrown to workflow code; replay restores the same class and code without a client call.
Returns INVALID_START_PARAMETERS The driver records and throws that rejection after one client call.
Throws No step is recorded, workflow code cannot catch the error, and the next execution attempt calls the client again.
Caller stops after accepted but before recording the step The next attempt calls the client with the same start-request ID and records its first execution ID.

Runner tests will cover outcomes:

Case Observable outcome
Workflow returns deliver resolves with completed and the result.
Workflow throws outside a step deliver resolves with failed and the error.
An infrastructure operation fails during a run deliver rejects, the worker replies retry, and no outcome is recorded.
A delay suspends the workflow The unfinished step and its wake are recorded before deliver resolves with suspended.

Delivery tests will cover both invokers and the dispatcher:

Delivery outcome Observable outcome
deliver resolves The dispatcher acknowledges the outcome under its lease.
deliver rejects, the worker replies retry, or the child exits before an outcome The lease is released; the execution is delivered again after the redelivery delay.
WorkflowStartClient throws The driver records no step, workflow code cannot catch the error, deliver rejects, and the execution is delivered again.
Direct trigger is called It resolves once admission commits; the dispatcher delivers the execution.
Worker and test invokers receive the same event and runner behavior Their deliver results match.

Not Specified

This RFC will not choose:

  • how start-request records are stored, or which records a runtime adapter keeps;
  • transaction boundaries inside a runtime adapter, beyond the all-or-nothing guarantee for a new start; or
  • the driver-internal step responses that carry a start request or an outcome.
主要语言
TypeScript
星标
2
派生
0
PR 合并指标
30 天内没有已合并 PR

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

notationlabs/yieldstar 的其他 Issue

查看 notationlabs/yieldstar 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。