Workflows:可持久執行的多步驟流程
前面二十章裡,我們把「非同步」這件事拆成了三種工具: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 不再執行,直接吐出上次的結果。
這個「重新執行 + 記憶化」的模型,是本章唯一真正需要理解的東西。它決定了幾乎所有你會踩到的坑。
21.1 最小可執行的 Workflow
Section titled “21.1 最小可執行的 Workflow”一個 Workflow 是一個 WorkflowEntrypoint 子類別,並在 wrangler.jsonc 裡宣告。
{ "workflows": [ { "name": "ch21-report", "binding": "REPORT", "class_name": "ReportWorkflow" } ]}三個欄位都是必填(binding、name、class_name)。可選欄位有 script_name(跨 Worker 綁定,語意同第 18 章的 service binding)、limits、schedules。
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.env 和 this.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.name、step.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() 的處理方式是:
- 從頭執行
run()。 - 遇到
step.do("A", fn):查 execution history 有沒有A的結果。有 → 直接回傳,不執行fn;沒有 → 執行fn,把回傳值序列化寫進 storage。 - 遇到
step.sleep/waitForEvent:把 instance 休眠,run()的這次執行結束。 - 醒來時,回到第 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 永遠是第一次執行時的那個毫秒值。
一個必須知道的本地開發限制
Section titled “一個必須知道的本地開發限制”上面第 3、4 步 —— 休眠與 replay —— 在本地 wrangler dev 不會真的發生。實測:一個帶 step.sleep("hibernate", "3 seconds") 的 instance,模組層的 trace 只印出一次 run start:
05:17:49.983 run start mode=sensitive instanceId=sen-205:17:50.080 flaky attempt=1 name=flaky count=105:17:50.144 secret step body ran05: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”標準 retry policy
Section titled “標準 retry policy”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=105:16:24.911 flaky attempt=2 name=flaky count=105:16:25.996 flaky attempt=3 name=flaky count=1間隔約 1.09 秒,符合 constant + 1 second。limit: 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 可以是一個函式
Section titled “delay 可以是一個函式”型別定義裡有一項官方文件完全沒提的能力: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-105:16:31.339 delayFn attempt=2 error=boom-2 <- 間隔 1.05s05: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";}NonRetryableError
Section titled “NonRetryableError”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.name 是 WorkflowFatalError,不是 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,得到的 attempts 是 109。因為那 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傳進來。
21.6 sleep 與 waitForEvent
Section titled “21.6 sleep 與 waitForEvent”await step.sleep("nap", "3 seconds");await step.sleepUntil("until", Date.now() + 2000);sleep 吃 duration,sleepUntil 吃 Date 或 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" }丟出的是普通的 Error,name 就是 "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 { // 目前無法可靠區分逾時與其他失敗,一律走預設路徑}sendEvent 的三個靜默行為
Section titled “sendEvent 的三個靜默行為”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 }。
21.8 instance 生命週期控制
Section titled “21.8 instance 生命週期控制”WorkflowInstance 的 prototype 實測為:
["constructor", "getInstance", "pause", "resume", "terminate", "restart", "status", "sendEvent"]| 操作 | 實測行為 |
|---|---|
pause() | status → paused;dev server log 會印出 Uncaught Error: Aborting engine: User called pause,這是正常的中止機制,不是 bug |
resume() | status → running,從中斷處續跑 |
terminate() | status → terminated,output 為 null,之後不再變化 |
restart() | 清空 execution history,從頭重跑。實測可以對一個 terminated 的 instance 呼叫 |
restart({ from: { name } }) | 保留該 step 之前的所有快取結果,從指定 step 重跑 |
restart({ from }) 的實測驗證 —— stamp step 的值在 restart 前後改變了,而它前面的 step 沒有重跑:
stamp before restart: 1785561532777stamp after restart: 1785561535166指定不存在的 step 會拿到一個帶錯誤碼的訊息:
Error: WorkflowError: (instance.cannot_restart) Step "no-such-step" not found in execution historyfrom 還接受 count(同名 step 的第幾次出現,1-indexed,預設 1)和 type("do" | "sleep" | "waitForEvent",當不同型別的 step 同名時用來消歧義)。這組參數在 production 事故處理時很有用:不必重跑整條流程,只從壞掉的那一步接續。
wf.get() 會丟錯
Section titled “wf.get() 會丟錯”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”create() 與重複 ID
Section titled “create() 與重複 ID”型別註解寫得很清楚:“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" }createBatch()
Section titled “createBatch()”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 頁面而不是本地行為。
retention
Section titled “retention”型別定義裡 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 調到幾小時可以顯著壓低儲存成本。
cron 觸發
Section titled “cron 觸發”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 才會發現。
limits
Section titled “limits”{ "name": "...", "binding": "...", "class_name": "...", "limits": { "steps": 25000 } }這個 limits 和頂層 Worker 的 limits(cpu_ms、subrequests)是不同的鍵,只是剛好同名,官方文件從未點出這個區別。Wrangler 的 configuration 參考頁甚至沒有列出 workflows[].limits,只有 Workflows 的 Workers API 頁面有。付費方案 step 上限預設 10,000,可調到 25,000;免費方案 1,024 且不可調。
21.10 sensitive: "output"
Section titled “21.10 sensitive: "output"”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 不離開 stepawait step.do("call-vendor", { sensitive: "output" }, async () => { const token = await mintToken(); const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } }); return { status: res.status }; // 回傳值本身就不敏感});21.11 binding 上的 unsafe* API
Section titled “21.11 binding 上的 unsafe* API”實測 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 命名已經說明了一切:不要寫進任何會被部署的程式碼。
21.12 什麼時候該用 Workflows
Section titled “21.12 什麼時候該用 Workflows”官方文件沒有任何一頁比較 Workflows、Queues 與 DO alarms。以下是綜合前幾章實測結果的判斷框架,不是官方立場。
| Queues(第 19 章) | DO alarm(第 17 章) | Workflows | |
|---|---|---|---|
| 記住「走到第幾步」 | 你自己存 | 你自己存 | 平台負責 |
| 失敗重試粒度 | 整則訊息 | 整個 alarm handler | 單一 step |
| 重試上限 | max_retries + DLQ | 實測 7 次後靜默丟棄 | 每 step 最多 10,000 次 |
| 等待外部事件 | 無 | 自己實作 | waitForEvent(最長 365 天) |
| 長時間等待成本 | N/A | 佔用 DO | waiting 不計 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。
21.13 本章實測結論彙整
Section titled “21.13 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | 預設 retry 是 {limit:5, delay:1000, backoff:"exponential"},文件寫 delay:10000 | 退避時程估算差一個數量級 |
| 2 | step.do 的 callback 收 ctx,含 attempt/step.name/step.count/config | 這是唯一正確的重試計數來源 |
| 3 | retries.delay 可以是 ({ctx, error}) => duration 函式(文件未載) | 可實作 Retry-After 感知的退避 |
| 4 | NonRetryableError 的終態 error.name 是 WorkflowFatalError | 告警規則要對這個字串 |
| 5 | 模組層變數在同一 isolate 內被所有 instance 共用(實測污染到 109) | Workflow instance ≠ DO,沒有記憶體隔離 |
| 6 | 本地 wrangler dev 不會 hibernate / replay run() | replay-safety 本地測不出來 |
| 7 | waitForEvent 回傳 {payload, type, timestamp} 信封,非裸 payload | 文件寫 Promise<void>,是錯的 |
| 8 | waitForEvent 逾時丟普通 Error,訊息 Execution timed out after Nms | 無專屬錯誤類別可判斷 |
| 9 | sendEvent 對「type 不匹配」和「instance 已完成」都靜默成功 | event type 打錯會靜靜等到逾時 |
| 10 | step.do 第三參數可註冊 rollback handler,收 {ctx, error, output} | 平台內建 saga 補償 |
| 11 | terminate({rollback:true}) 會先跑補償 | 人工中止也能保持一致性 |
| 12 | InstanceStatus 型別沒宣告 rollback,runtime 有回傳 | 型別比 runtime 窄(本系列第三次) |
| 13 | wf.get() 對不存在的 ID 丟 instance.not_found,沒有 exists() | 必須 try/catch |
| 14 | 本地重複 create({id}) 不丟錯、靜默回傳既有 instance | 文件說會丟錯,production 行為不同 |
| 15 | restart({from:{name}}) 保留該 step 之前的快取結果 | 事故處理不必重跑整條流程 |
| 16 | create() 支援 retention.{success,error}Retention(文件站未載) | 直接影響 storage 計費 |
| 17 | sensitive: "output" 讓持久化的值變成 "[REDACTED]" | 未載於文件;推論 replay 後拿不回真值 |
| 18 | sleep 期間本地 status() 回報 running 而非 waiting | 狀態判斷要照型別的九個值寫 |
| 19 | schedules: ["not a cron"] 通過 deploy --dry-run | cron 語法錯誤要到 deploy 才炸 |
| 20 | workflows[].limits 與頂層 limits 同名但不同鍵 | 極易誤配置;Wrangler 參考頁未列前者 |
21.14 動手練習
Section titled “21.14 動手練習”- 把範例的
flakystep 改成用Math.random()決定是否失敗,觀察ctx.attempt與模組層計數器在createBatch(5)之後的差異。 - 為 LinkForge 寫一個
TenantOnboardingWorkflow:建立 D1 租戶列 → 建立 R2 前綴 →waitForEvent等待 email 驗證(逾時 1 小時)→ 發 API key。為前兩步各註冊一個 rollback handler,然後用terminate({rollback:true})驗證補償順序。 - 用
restart({ from: { name, count } })讓一個含迴圈的 Workflow 只從第 3 圈重跑,觀察ctx.step.count。 - 量測
retention: { successRetention: "1 hour" }與預設值在 dashboard 上的 storage 差異(需要 production 環境)。
- Workflows — Workers API
- Rules of Workflows
- Sleeping and retrying
- Events and parameters
- Trigger Workflows
- Workflows limits
- Workflows pricing
- 型別來源:
node_modules/@cloudflare/workers-types(經wrangler types產生的worker-configuration.d.ts) - 設定 schema 來源:
node_modules/wrangler/config-schema.json
下一章(第 22 章)進入可觀測性的資料面:Analytics Engine —— 如何在不撞上 D1 寫入瓶頸的前提下,替 LinkForge 記錄每一次點擊。