跳到內容

Workflows:可持久執行的多步驟流程

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24

前面二十章裡,我們把「非同步」這件事拆成了三種工具:ctx.waitUntil()(第 3 章)處理請求結束後的殘餘工作、Queues(第 19 章)處理需要重試的批次投遞、Durable Object alarms(第 17 章)處理定時喚醒。三者都有同一個缺口:它們記不住「流程走到哪裡了」

一個「使用者升級付費方案」的流程可能長這樣:扣款 → 建立租戶資源 → 寄送歡迎信 → 等待對方確認 Email → 開通 API key。如果第三步失敗了,你不能把整段重跑一次,否則會重複扣款。用 Queues 實作,你得自己設計一張「流程狀態表」,把每一步的結果寫進 D1,再靠 message 裡的 stage 欄位決定下一步。這段狀態機的程式碼,通常比業務邏輯本身還長。

Workflows 就是把這段狀態機收進 platform。你寫的是一段看起來完全同步的 async 函式,engine 負責在每個 step 邊界把回傳值持久化;當流程中斷(instance 被 evict、Worker 重新部署、機器故障),engine 會重新執行你的函式,但已經完成的 step 不再執行,直接吐出上次的結果。

這個「重新執行 + 記憶化」的模型,是本章唯一真正需要理解的東西。它決定了幾乎所有你會踩到的坑。


一個 Workflow 是一個 WorkflowEntrypoint 子類別,並在 wrangler.jsonc 裡宣告。

{
"workflows": [
{
"name": "ch21-report",
"binding": "REPORT",
"class_name": "ReportWorkflow"
}
]
}

三個欄位都是必填(bindingnameclass_name)。可選欄位有 script_name(跨 Worker 綁定,語意同第 18 章的 service binding)、limitsschedules

import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from "cloudflare:workers";
type Params = { tenantId: string };
export class ReportWorkflow extends WorkflowEntrypoint<CloudflareBindings, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep): Promise<unknown> {
const rows = await step.do("fetch-rows", async () => {
return await this.env.DB.prepare("SELECT ...").all();
});
await step.sleep("cool-down", "10 seconds");
return await step.do("write-report", async () => {
await this.env.BUCKET.put(`reports/${event.payload.tenantId}.json`, JSON.stringify(rows));
return { ok: true };
});
}
}

注意 WorkflowEntrypoint 和第 18 章的 WorkerEntrypoint 是同一套繼承模式:this.envthis.ctx 由 base class 提供。

觸發則透過 binding:

const inst = await env.REPORT.create({ id: "tenant-42-2026-08", params: { tenantId: "42" } });
await inst.status(); // { status: "running", output: null }

21.2 WorkflowEvent 與 step context 的實際形狀

Section titled “21.2 WorkflowEvent 與 step context 的實際形狀”

先把兩個物件攤開來看。範例的 inspect step 直接把 event 和 step callback 收到的 ctx 印出來:

const shape = await step.do("inspect", async (ctx) => ({
eventKeys: Object.keys(event),
timestampIsDate: event.timestamp instanceof Date,
hasSchedule: "schedule" in event,
stepProto: Object.getOwnPropertyNames(Object.getPrototypeOf(step)),
ctxKeys: Object.keys(ctx),
ctx,
}));

本地實測輸出:

{
"eventKeys": ["timestamp", "payload", "instanceId", "workflowName"],
"timestampIsDate": true,
"hasSchedule": false,
"stepProto": ["dup", "constructor"],
"ctxKeys": ["step", "attempt", "config"],
"ctx": {
"step": { "name": "inspect", "count": 1 },
"attempt": 1,
"config": {
"retries": { "limit": 5, "backoff": "exponential", "delay": 1000 },
"timeout": "10 minutes"
}
}
}

四件值得停下來看的事:

(一)step.do 的 callback 有參數。 官方文件的範例幾乎都寫成 async () => {...},很容易讓人以為 callback 不收參數。實際上它收到一個 WorkflowStepContext,裡面有 attempt(目前是第幾次嘗試,1-indexed)、step.namestep.count(同名 step 的第幾次出現)、以及這個 step 生效的完整 retry 設定。這是你唯一能在 step 內部知道「我現在是重試」的正當管道 —— 稍後 21.5 會說明為什麼不能用模組變數計數。

(二)沒有明確設定時,runtime 的預設 retry 是 { limit: 5, delay: 1000, backoff: "exponential" } 官方文件的 Sleeping and retrying 頁面寫的是 delay: 10000(10 秒)。runtime 回報的是 1000 ms。差了一個數量級。timeout: "10 minutes" 則與文件一致。

這件事的實務影響:如果你依照文件估算「一個 step 失敗到徹底放棄要多久」,指數退避從 10 秒起跳大約是 10+20+40+80 秒;從 1 秒起跳則是 1+2+4+8 秒。前者你會覺得系統卡住,後者你會覺得重試太快打爆下游。以 runtime 回報的值為準,或乾脆每個 step 都明寫 retry policy。

(三)step 的 prototype 只有 ["dup", "constructor"] 也就是說 step.do / step.sleep / step.waitForEvent 都是 instance own property,不是 prototype method。實務意義:const { do: doStep } = step 這種解構是安全的(不會掉 this),但也代表你無法用 prototype patch 去攔截它們。

(四)event 上沒有 schedule 只有 cron 觸發的 instance 才會有這個欄位(見 21.9)。


21.3 step 是記憶化的,run() 會被重跑

Section titled “21.3 step 是記憶化的,run() 會被重跑”

這是整個模型的核心。engine 對 run() 的處理方式是:

  1. 從頭執行 run()
  2. 遇到 step.do("A", fn):查 execution history 有沒有 A 的結果。有 → 直接回傳,不執行 fn;沒有 → 執行 fn,把回傳值序列化寫進 storage。
  3. 遇到 step.sleep / waitForEvent:把 instance 休眠,run() 的這次執行結束。
  4. 醒來時,回到第 1 步 —— 整個 run() 從頭再跑一次,前面的 step 全部走記憶化路徑。

所以「step 名稱」就是 cache key。這推導出兩條硬性規則:

  • step 名稱必須是決定性的。 step.do(\sync-${Date.now()}`, …)` 會讓每次 replay 都產生新的 cache key,等於這個 step 從來沒有被記憶化過,每次 replay 都重跑一次。
  • step 之外的程式碼會執行多次。 run() 開頭那行 console.log、那個 const client = new SomeClient()、那個 counter++,在每次 replay 都會再跑一遍。

範例裡的 stamp step 就是驗證這件事的探針:

const stamped = await step.do("stamp", async () => Date.now());

同一個 instance 不論被 replay 幾次,stamped 永遠是第一次執行時的那個毫秒值。

上面第 3、4 步 —— 休眠與 replay —— 在本地 wrangler dev 不會真的發生。實測:一個帶 step.sleep("hibernate", "3 seconds") 的 instance,模組層的 trace 只印出一次 run start

05:17:49.983 run start mode=sensitive instanceId=sen-2
05:17:50.080 flaky attempt=1 name=flaky count=1
05:17:50.144 secret step body ran
05:17:50.190 secret in memory: {"token":"sk-live-do-not-log"}
05:17:53.340 run end

沒有第二次 run start。本地 engine 只是 await 了 3 秒,沒有序列化狀態、沒有重新執行 run()

這代表 Workflows 最重要的正確性性質 —— 「你的 run() 必須是 replay-safe」—— 恰好是本地測不出來的那一個。 本地跑得過的 Workflow,在 production 第一次遇到 hibernation 就可能行為不同。第 38 章會用 unsafeGetInstanceModifier 這類 introspection API 在測試裡強制觸發 replay;在那之前,把 21.5 的規則當成紀律而不是建議。


21.4 retry:ctx.attempt、dynamic delay、以及 NonRetryableError

Section titled “21.4 retry:ctx.attempt、dynamic delay、以及 NonRetryableError”
const retried = await step.do(
"flaky",
{ retries: { limit: 3, delay: "1 second", backoff: "constant" }, timeout: "30 seconds" },
async (ctx) => {
if (ctx.attempt < 3) throw new Error("transient");
return { attempt: ctx.attempt };
},
);

實測 trace:

05:16:23.823 flaky attempt=1 name=flaky count=1
05:16:24.911 flaky attempt=2 name=flaky count=1
05:16:25.996 flaky attempt=3 name=flaky count=1

間隔約 1.09 秒,符合 constant + 1 secondlimit: 3 意思是總共嘗試 3 次attempt 從 1 數到 3),不是「失敗後再重試 3 次」。這和第 19 章 Queues 的 max_retries: 2 = 3 次投遞 是不同的計數慣例 —— Queues 數的是 retry,Workflows 數的是 attempt。跨章節對照時特別容易搞錯。

delay 接受 "1 second" 這種 WorkflowSleepDuration 字串(單位限 second/minute/hour/day/week/month/year,可加 s),或直接給毫秒數。

型別定義裡有一項官方文件完全沒提的能力:delay 可以是 (input: { ctx, error }) => WorkflowDelayDuration

await step.do(
"dynamic",
{
retries: {
limit: 4,
delay: ({ ctx, error }) => {
log(`delayFn attempt=${ctx.attempt} error=${error.message}`);
return `${ctx.attempt} seconds`;
},
backoff: "constant",
},
},
async (ctx) => {
if (ctx.attempt < 3) throw new Error(`boom-${ctx.attempt}`);
return { settledOn: ctx.attempt };
},
);

實測:

05:16:30.284 delayFn attempt=1 error=boom-1
05:16:31.339 delayFn attempt=2 error=boom-2 <- 間隔 1.05s
05:16:33.446 run end <- 間隔 2.10s

函式在失敗之後被呼叫,ctx.attempt 是剛剛失敗的那一次。回傳值決定下一次重試前要等多久。這個能力在處理 429 Retry-After 這類「下游告訴你該等多久」的場景非常實用:

delay: ({ error }) => {
const m = /retry after (\d+)/i.exec(error.message);
return m ? `${m[1]} seconds` : "10 seconds";
}
import { NonRetryableError } from "cloudflare:workflows";
await step.do("fatal", async () => {
throw new NonRetryableError("this must not be retried");
});

實測 instance 終態:

{
"status": "errored",
"output": null,
"error": {
"name": "WorkflowFatalError",
"message": "The execution of the Workflow instance was terminated, as a step threw an NonRetryableError and it was not handled"
}
}

注意 error.nameWorkflowFatalError,不是 NonRetryableError —— engine 把它包了一層。如果你打算靠 error.name 分流告警,要對的是 WorkflowFatalError。文件沒有記載這個字串。


21.5 為什麼「不要依賴 step 之外的狀態」不是建議而是規則

Section titled “21.5 為什麼「不要依賴 step 之外的狀態」不是建議而是規則”

官方 Rules of Workflows 的第 3 條是「Do not rely on state outside of a step」。範例裡我故意留了一個反面教材:

const trace: string[] = []; // 模組層
const log = (s: string) => void trace.push(...);

這個陣列有兩個問題,而第二個問題比第一個嚴重得多。

問題一:replay 之後它是空的。 engine 重新執行 run() 時可能落在一個全新的 isolate,模組層變數回到初始值。任何靠它做的判斷都會得到錯誤答案。

問題二:它在同一個 isolate 裡被所有 instance 共用。 這一點在我測試過程中意外暴露出來 —— 當時我用 trace.filter(t => t.includes("flaky attempt")).length 來判斷重試次數,跑完一輪 createBatch(101) 之後再啟一個新 instance,得到的 attempts109。因為那 101 個 instance 全都往同一個模組層陣列裡寫。

Workflow instance 不是 Durable Object。DO 的 isolate 邊界就是 instance 邊界(第 14 章),一個 DO 的記憶體只屬於它自己。Workflow 沒有這個保證:同一個 Worker script 的所有 instance 可能在同一個 isolate 裡執行。這是一個很容易誤推的類比,因為兩者的 API 表面都長得像「有狀態的物件」。

正確的做法就是 ctx.attempt

async (ctx) => {
if (ctx.attempt < 3) throw new Error("transient");
return { attempt: ctx.attempt };
}

這個值由 engine 提供,跨 replay 正確,跨 instance 隔離。

同樣的邏輯延伸出去:

  • 需要跨 step 傳遞資料 → 用前一個 step 的回傳值,不要用閉包外的變數。
  • 需要建立 DB 連線這類不可序列化的資源 → 在每個 step 內部重新建立,不要在 run() 開頭建一次。
  • 需要亂數或時間 → 包進 step.do 讓它被記憶化,或從 event.payload 傳進來。

await step.sleep("nap", "3 seconds");
await step.sleepUntil("until", Date.now() + 2000);

sleep 吃 duration,sleepUntilDate 或 epoch 毫秒。上限都是 365 天。

一個容易被忽略的計費/配額細節:官方 limits 頁面明說 waiting 狀態的 instance 不計入 concurrency 限制,且 sleep 期間不產生 CPU time 費用。所以「等 7 天後寄提醒信」在 Workflows 裡是免費的等待,而在 DO alarm 裡你得自己管理喚醒鏈。

實測本地行為:sleep 期間 status() 回報 running,不是型別定義裡那個 waiting

t+1s running t+2s running ... t+5s running t+6s complete

型別定義的九個狀態是:

type WorkflowInstanceStatus =
| "queued" | "running" | "paused" | "errored" | "terminated"
| "complete" | "waiting" | "waitingForPause" | "unknown";

本地 engine 只用得到其中一部分。不要用本地觀察到的狀態集合去寫 production 的狀態判斷邏輯,要對照型別定義涵蓋全部九個。

另外實測確認:step.sleep / sleepUntil 不會出現在 step 輸出清單裡(只有 step.do 會產生記憶化的輸出),這與官方「sleep 不計入 step 上限」的說明一致。

waitForEvent:文件與型別互相矛盾的地方

Section titled “waitForEvent:文件與型別互相矛盾的地方”

官方 Workers API 頁面把簽章寫成 step.waitForEvent(name: string, options: ): Promise<void> —— 注意 options: 後面是空的,而且回傳型別寫成 Promise<void>。這是錯的。生成的型別定義才是對的:

waitForEvent<T extends Rpc.Serializable<T>>(
name: string,
options: { type: string; timeout?: WorkflowTimeoutDuration | number },
): Promise<WorkflowStepEvent<T>>;
type WorkflowStepEvent<T> = {
payload: Readonly<T>;
timestamp: Date;
type: string;
sensitive?: "output";
};

實測回傳值,確認是一個信封(envelope)而不是裸 payload:

{
"keys": ["payload", "type", "timestamp"],
"value": {
"payload": { "by": "alice" },
"type": "approve",
"timestamp": "2026-08-01T05:18:20.119Z"
},
"timestampIsDate": true
}

所以要拿資料必須 .payload

const ev = await step.waitForEvent<Approval>("approval", { type: "approve", timeout: "1 hour" });
if (ev.payload.approved) { ... }

timeout 預設 24 小時,範圍 1 秒到 365 天。逾時的行為實測:

{ "threwName": "Error", "threw": "Error: Execution timed out after 3000ms" }

丟出的是普通的 Errorname 就是 "Error",沒有專屬的錯誤類別。要區分「逾時」和「其他錯誤」,目前只能比對 message 字串 —— 這很脆弱,建議把 waitForEvent 包在 try/catch 裡,然後把 catch 分支當成「一律視為逾時」處理:

let approval: Approval | null = null;
try {
approval = (await step.waitForEvent<Approval>("a", { type: "approve", timeout: "1 hour" })).payload;
} catch {
// 目前無法可靠區分逾時與其他失敗,一律走預設路徑
}
await inst.sendEvent({ type: "approve", payload: { by: "alice" } });

實測三種邊界情形,全部靜默成功,不丟錯:

情境實測結果
instance 還沒跑到 waitForEvent事件被緩衝,之後正常送達
type 與任何 waitForEvent 都不匹配呼叫成功,instance 繼續 running,最後逾時
instance 已經 complete呼叫成功,什麼都沒發生

第一種是文件承諾的行為(事件會被緩衝),很好。第二、三種是真正的陷阱type 打錯一個字,你的 approval 流程會靜靜地等到 24 小時預設逾時為止,而發送端拿到的是一個成功的回應。

官方文件說 sendEvent 在 instance「not running or errored」時會丟例外 —— 本地 engine 並不會。這是 local/production 分歧,但無論哪邊,把 event type 抽成共用常數都是必要的防護:

export const EVT_APPROVE = "approve" as const;

21.7 rollback:平台內建的 saga 補償

Section titled “21.7 rollback:平台內建的 saga 補償”

型別定義裡有一組官方文件尚未涵蓋的 API:step.do 的第三個參數可以註冊 rollback handler。

await step.do(
"charge",
async () => {
await chargeCard();
return { chargeId: "ch_1" };
},
{
rollback: async ({ ctx, error, output }) => {
// output 是這個 step 當初成功的回傳值
await refund(output.chargeId);
},
},
);
await step.do("ship", async () => {
throw new NonRetryableError("warehouse is on fire");
});

實測結果 —— ship 失敗後,charge 的 rollback 自動被觸發

{
"trace": [
"05:16:48.073 run start mode=rollback instanceId=rb-1",
"05:16:48.365 rollback charge step=charge err=NonRetryableError: warehouse is on fire out={\"chargeId\":\"ch_1\"}"
],
"sideEffects": ["charged", "refunded"]
}

rollback handler 收到的 context 有三樣東西:ctx{step:{name,count}, attempt, config})、error(讓流程失敗的那個錯誤)、output(這個 step 當初的回傳值,用來知道要補償什麼)。

還可以主動觸發:inst.terminate({ rollback: true }) 會在終止前先跑完所有已註冊的 rollback handler。只有註冊過 handler 的 step 會被補償。

rollback 也可以有自己的 retry policy:rollbackConfig?: Pick<WorkflowStepConfig, "retries" | "timeout">

型別定義裡的 InstanceStatus 沒有宣告 rollback 欄位,但 runtime 實際回傳了 rollback: null。這是本系列第三次遇到「型別比 runtime 窄」(第 15 章 raw().toArray()、第 18 章 service binding 型別不流動)。本地 engine 即使真的跑了 rollback,這個欄位仍然是 null —— 要判斷補償是否成功,production 上才會拿到 { outcome, error }


WorkflowInstance 的 prototype 實測為:

["constructor", "getInstance", "pause", "resume", "terminate", "restart", "status", "sendEvent"]
操作實測行為
pause()status → pauseddev server log 會印出 Uncaught Error: Aborting engine: User called pause,這是正常的中止機制,不是 bug
resume()status → running,從中斷處續跑
terminate()status → terminatedoutputnull,之後不再變化
restart()清空 execution history,從頭重跑。實測可以對一個 terminated 的 instance 呼叫
restart({ from: { name } })保留該 step 之前的所有快取結果,從指定 step 重跑

restart({ from }) 的實測驗證 —— stamp step 的值在 restart 前後改變了,而它前面的 step 沒有重跑:

stamp before restart: 1785561532777
stamp after restart: 1785561535166

指定不存在的 step 會拿到一個帶錯誤碼的訊息:

Error: WorkflowError: (instance.cannot_restart) Step "no-such-step" not found in execution history

from 還接受 count(同名 step 的第幾次出現,1-indexed,預設 1)和 type"do" | "sleep" | "waitForEvent",當不同型別的 step 同名時用來消歧義)。這組參數在 production 事故處理時很有用:不必重跑整條流程,只從壞掉的那一步接續。

const inst = await env.REPORT.get("does-not-exist");

實測回應 HTTP 500

Error: instance.not_found
at WorkflowBinding.get (.../workflows-shared/src/binding.ts:220:10)

binding 上沒有 exists()tryGet()。所有 get() 都必須包在 try/catch 裡,否則使用者查一個過期的 instance ID(免費方案保留 3 天、付費 30 天)就會拿到 500。


21.9 建立 instance:id、batch、retention、cron

Section titled “21.9 建立 instance:id、batch、retention、cron”

型別註解寫得很清楚:“If a provided id exists, an error will be thrown.”

實測本地:不會丟錯。第二次 create({ id: "dupz" }) 靜默回傳了既有 instance 的 handle,stamp 的值完全相同,代表流程沒有重跑。

{ "ok": { "id": "dupz", "status": { "status": "complete", ... } } }

這是一個危險的 local/production 分歧:本地你會以為「重複觸發是冪等的」,production 會丟錯。實務上請自己攔:

try {
await env.REPORT.create({ id, params });
} catch (e) {
// production: 已存在。決定要 restart 還是視為已處理
}

ID 長度上限 100 字元是本地就會擋的:

{ "threwName": "Error", "threw": "Error: Workflow instance has invalid id" }
const created = await env.REPORT.createBatch([
{ id: "a", params: { tenantId: "1" } },
{ id: "b", params: { tenantId: "2" } },
]);

文件說:上限 100 個 instance(或 batch 的 RPC 上限 1 MiB),而且一次 createBatch 只算一次 create 配額 —— 這是繞過「每秒 100 次建立」限制的正規手段。文件同時說 createBatch 是冪等的,已存在的 ID 會被跳過並排除在回傳陣列外。

實測本地兩處分歧:

行為文件本地實測
createBatch 101 筆上限 100成功,回傳 101 個 handle
重複 ID跳過,排除在回傳外回傳 count: 2,ID 原樣列出

另外:測試中連續呼叫 createBatch(101) 之後,本地 dev server 直接崩潰並印出一個空的 ERROR。本地 engine 不是為了壓力測試設計的,估算吞吐量請看 limits 頁面而不是本地行為。

型別定義裡 WorkflowInstanceCreateOptions 有一個文件站上找不到的欄位:

retention?: {
successRetention?: WorkflowRetentionDuration;
errorRetention?: WorkflowRetentionDuration;
};
await env.REPORT.create({
id,
params,
retention: { successRetention: "1 hour", errorRetention: "7 days" },
});

本地實測接受此參數。這正好回答了 pricing 頁面那句「retention 可以調低以降低儲存費用」卻沒說怎麼調的懸念 —— 它是 per-instance 的 create option。Workflows 的三個計費維度是 requests、CPU time、storage(GB-month),對於高頻但輸出很小的流程,把 successRetention 調到幾小時可以顯著壓低儲存成本。

schedules 讓 Workflow 不必經由 Worker 就能定期自動建立 instance:

{
"workflows": [
{
"name": "ch21-report",
"binding": "REPORT",
"class_name": "ReportWorkflow",
"schedules": ["0 * * * *"]
}
]
}

此時 event.schedule 會被填入:

type WorkflowCronSchedule = { cron: string; scheduledTime: number };
if (event.schedule) {
console.log(event.schedule.cron, new Date(event.schedule.scheduledTime));
}

與第 20 章的 Cron Triggers 比較:Cron Trigger 觸發的是 Worker 的 scheduled handler,受 Worker 的 CPU 時間限制;schedules 觸發的是一個完整的 Workflow instance,可以跑好幾天。limits 頁面另外提到:付費方案上,由 schedules 建立的 instance 在每次 cron 觸發後一小時內不佔用 concurrency 額度

一個部署期的坑:實測把 schedules 設成 ["not a cron"]wrangler deploy --dry-run 完全不報錯,正常輸出 bundle。cron 語法錯誤要到真正 deploy 才會發現。

{ "name": "...", "binding": "...", "class_name": "...", "limits": { "steps": 25000 } }

這個 limits 和頂層 Worker 的 limitscpu_mssubrequests)是不同的鍵,只是剛好同名,官方文件從未點出這個區別。Wrangler 的 configuration 參考頁甚至沒有列出 workflows[].limits,只有 Workflows 的 Workers API 頁面有。付費方案 step 上限預設 10,000,可調到 25,000;免費方案 1,024 且不可調。


WorkflowStepConfig 有第三個欄位,文件站上完全沒有:

export type WorkflowStepConfig = {
retries?: { ... };
timeout?: WorkflowTimeoutDuration | number;
sensitive?: "output";
};
const secret = await step.do("secret", { sensitive: "output" }, async () => ({
token: "sk-live-do-not-log",
}));

實測 —— 持久化的 step 輸出被替換成字面字串 "[REDACTED]"

["[REDACTED]", { "secretAfterReplay": { "token": "sk-live-do-not-log" } }, 1785561473336]

而同一次執行中,callback 的回傳值在記憶體裡仍是真值。

這裡有一個必須自己想清楚的推論。 既然持久化的值是 "[REDACTED]",而 replay 時 step.do 是從持久化的值回傳的,那麼一個 sensitive step 在 replay 之後理應回傳 "[REDACTED]" 而不是真的 token。我無法在本地證實這一點,因為 21.3 已經說明本地根本不會 replay。

保守的用法是把 sensitive: "output" 當成「這個值不可跨 step 邊界使用」的宣告:在同一個 step 內部把 secret 用掉,只回傳非敏感的結果。

// 建議:secret 不離開 step
await step.do("call-vendor", { sensitive: "output" }, async () => {
const token = await mintToken();
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
return { status: res.status }; // 回傳值本身就不敏感
});

實測 Object.getOwnPropertyNames(Object.getPrototypeOf(env.REPORT))

[
"constructor", "get", "create", "createBatch",
"unsafeGetBindingName", "unsafeStartIntrospection", "unsafeStopIntrospection",
"unsafeSetIntrospectionOperations", "unsafeGetIntrospectionInstances",
"unsafeAbort", "unsafeGetInstanceModifier", "unsafeWaitForStepResult",
"unsafeWaitForStatus", "unsafeGetOutputOrError"
]

unsafe 前綴是明確的訊號:不要在 production 程式碼裡呼叫它們。它們存在的原因是支撐 @cloudflare/vitest-pool-workers 的 Workflow 測試 API —— 讓測試可以「快轉 sleep」、「攔截某個 step 讓它失敗」、「等到某個狀態出現再斷言」。第 38 章會用到它們。

另外,本地 status() 的回傳值裡有一個 production 沒有的欄位 __LOCAL_DEV_STEP_OUTPUTS,是一個陣列,依序放每個 step.do 的記憶化輸出。它在本地除錯時極其好用(本章大部分實測就是靠它),但雙底線前綴 + LOCAL_DEV 命名已經說明了一切:不要寫進任何會被部署的程式碼。


官方文件沒有任何一頁比較 Workflows、Queues 與 DO alarms。以下是綜合前幾章實測結果的判斷框架,不是官方立場。

Queues(第 19 章)DO alarm(第 17 章)Workflows
記住「走到第幾步」你自己存你自己存平台負責
失敗重試粒度整則訊息整個 alarm handler單一 step
重試上限max_retries + DLQ實測 7 次後靜默丟棄每 step 最多 10,000 次
等待外部事件自己實作waitForEvent(最長 365 天)
長時間等待成本N/A佔用 DOwaiting 不計 concurrency、不計 CPU
吞吐量高(批次)建立速率 100–300/s
補償/回滾自己實作自己實作rollback handler

選擇的關鍵問題是:這件事有沒有「進度」?

  • 「把這 10 萬筆事件寫進 ClickHouse」—— 沒有進度,每則訊息獨立。用 Queues。
  • 「每 5 分鐘檢查這個租戶的配額」—— 沒有進度,每次都是全新的。用 Cron Trigger 或 DO alarm。
  • 「使用者升級方案:扣款 → 開資源 → 寄信 → 等確認 → 開 API key」—— 有進度,而且中間可能等好幾天。用 Workflows。

反過來說,Workflows 的建立速率上限(付費方案每個 workflow 每秒 100 個 instance)讓它不適合當 per-request 的處理管線。「每個 API 請求開一個 Workflow」在中等流量下就會撞牆。正確的組合往往是:Queues 負責吸收流量並批次化,consumer 再對需要多步驟的那一小部分 createBatch 出 Workflow instance。


#結論影響
1預設 retry 是 {limit:5, delay:1000, backoff:"exponential"},文件寫 delay:10000退避時程估算差一個數量級
2step.do 的 callback 收 ctx,含 attempt/step.name/step.count/config這是唯一正確的重試計數來源
3retries.delay 可以是 ({ctx, error}) => duration 函式(文件未載)可實作 Retry-After 感知的退避
4NonRetryableError 的終態 error.nameWorkflowFatalError告警規則要對這個字串
5模組層變數在同一 isolate 內被所有 instance 共用(實測污染到 109)Workflow instance ≠ DO,沒有記憶體隔離
6本地 wrangler dev 不會 hibernate / replay run()replay-safety 本地測不出來
7waitForEvent 回傳 {payload, type, timestamp} 信封,非裸 payload文件寫 Promise<void>,是錯的
8waitForEvent 逾時丟普通 Error,訊息 Execution timed out after Nms無專屬錯誤類別可判斷
9sendEvent 對「type 不匹配」和「instance 已完成」都靜默成功event type 打錯會靜靜等到逾時
10step.do 第三參數可註冊 rollback handler,收 {ctx, error, output}平台內建 saga 補償
11terminate({rollback:true}) 會先跑補償人工中止也能保持一致性
12InstanceStatus 型別沒宣告 rollback,runtime 有回傳型別比 runtime 窄(本系列第三次)
13wf.get() 對不存在的 ID 丟 instance.not_found,沒有 exists()必須 try/catch
14本地重複 create({id}) 不丟錯、靜默回傳既有 instance文件說會丟錯,production 行為不同
15restart({from:{name}}) 保留該 step 之前的快取結果事故處理不必重跑整條流程
16create() 支援 retention.{success,error}Retention(文件站未載)直接影響 storage 計費
17sensitive: "output" 讓持久化的值變成 "[REDACTED]"未載於文件;推論 replay 後拿不回真值
18sleep 期間本地 status() 回報 running 而非 waiting狀態判斷要照型別的九個值寫
19schedules: ["not a cron"] 通過 deploy --dry-runcron 語法錯誤要到 deploy 才炸
20workflows[].limits 與頂層 limits 同名但不同鍵極易誤配置;Wrangler 參考頁未列前者

  1. 把範例的 flaky step 改成用 Math.random() 決定是否失敗,觀察 ctx.attempt 與模組層計數器在 createBatch(5) 之後的差異。
  2. 為 LinkForge 寫一個 TenantOnboardingWorkflow:建立 D1 租戶列 → 建立 R2 前綴 → waitForEvent 等待 email 驗證(逾時 1 小時)→ 發 API key。為前兩步各註冊一個 rollback handler,然後用 terminate({rollback:true}) 驗證補償順序。
  3. restart({ from: { name, count } }) 讓一個含迴圈的 Workflow 只從第 3 圈重跑,觀察 ctx.step.count
  4. 量測 retention: { successRetention: "1 hour" } 與預設值在 dashboard 上的 storage 差異(需要 production 環境)。

下一章(第 22 章)進入可觀測性的資料面:Analytics Engine —— 如何在不撞上 D1 寫入瓶頸的前提下,替 LinkForge 記錄每一次點擊。