Cron Triggers:排程任務
這篇要解決的問題
Section titled “這篇要解決的問題”Cron Trigger 是 Workers 的排程入口:按 cron 運算式定期喚醒你的 Worker。概念上最簡單的一篇,但有四個實際會咬人的地方:
- 本機測試的方式換了。
--test-scheduled和/__scheduled已經從文件消失 —— 我實測/__scheduled回 404。 - 本機測試時
controller.cron預設是空字串,不是你設定的第一個 cron。用switch (controller.cron)分流的程式碼在本機會全部落到 default。 - 官方文件對「cron 數量是 per Worker 還是 per account」自相矛盾。
controller.type不存在。 文件提到它,但型別定義和 runtime 都沒有。
而最重要的一個判斷是:大部分你以為需要 cron 的事,其實應該用第 17 篇的 DO alarm。
{ "triggers": { "crons": [ "*/3 * * * *", // every 3 minutes "0 15 1 * *", // 15:00 UTC on the 1st of the month "59 23 LW * *", // last weekday of the month "0 18 * * friL" // last Friday of the month ] }}停用全部 cron:"crons": []。
全部以 UTC 執行。 沒有時區設定。
支援的欄位與字元:
| 欄位 | 值 | 支援字元 |
|---|---|---|
| 分 | 0–59 | * , - / |
| 時 | 0–23 | * , - / |
| 日 | 1–31 | * , - / L W |
| 月 | 1–12 或三字母縮寫 | * , - / |
| 星期 | 1–7 或三字母縮寫 | * , - / L # |
🔴 星期是 1 = 星期日 到 7 = 星期六,不是 POSIX 的 0–6。官方原文:「Days of the week go from 1 = Sunday to 7 = Saturday」。
這代表
0 10 7 * * mon-fri這種從別處抄來的運算式意義會不同。要平日就寫mon-fri或2-6。
L(last)和 W(weekday)是標準 cron 沒有的擴充:
0 18 * * 6L或0 18 * * friL—— 當月最後一個星期五59 23 LW * *—— 當月最後一個工作日
🔴 本機測試:舊方法已經沒了
Section titled “🔴 本機測試:舊方法已經沒了”實測:
$ curl -s localhost:8787/cdn-cgi/handler/scheduledok
$ curl -s -o /dev/null -w "%{http_code}" localhost:8787/__scheduled404/__scheduled 回 404。 wrangler dev --test-scheduled 也已經從現行文件消失。2025 年以前的教學會教這兩個,現在都不能用。
正確做法是普通的 wrangler dev + 打 /cdn-cgi/handler/scheduled:
# 觸發一次curl "http://localhost:8787/cdn-cgi/handler/scheduled"
# 拿到結構化結果curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json"# {"outcome":"ok","noRetry":false}
# 指定是哪個 cron 觸發的curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=0+15+1+*+*&format=json"
# 指定 scheduledTimecurl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*/3+*+*+*+*&time=1745856238000"🔴 不帶 ?cron= 時,controller.cron 是空字串
Section titled “🔴 不帶 ?cron= 時,controller.cron 是空字串”實測三種呼叫方式:
cron='' scheduledTime=1785560195010 (2026-08-01T04:56:35.010Z) -> branch: othercron='0 15 1 * *' scheduledTime=1785560195018 (2026-08-01T04:56:35.018Z) -> branch: monthlycron='*/3 * * * *' scheduledTime=1745856238000 (2025-04-28T16:03:58.000Z) -> branch: frequent不帶 ?cron= 時拿到的是 "",不是設定裡的第一個 cron。
所以這種很常見的分流寫法:
switch (controller.cron) { case "*/3 * * * *": return frequentJob(env); case "0 15 1 * *": return monthlyJob(env); default: return; // ← 本機測試會落在這裡}在本機測試時會全部落到 default,你的任務一個都不會跑,而且不會有任何錯誤。測試時一定要帶 ?cron=。
?time= 會設定 scheduledTime(上面第三行的 2025-04-28 就是我傳進去的)—— 這對測試「依照排程時間計算區間」的邏輯很有用。
ScheduledController 的真實形狀
Section titled “ScheduledController 的真實形狀”ownProps: ["cron", "scheduledTime"]proto: ["noRetry", "constructor"]type: null型別定義:
interface ScheduledController { readonly scheduledTime: number; readonly cron: string; noRetry(): void;}三個觀察:
① controller.type 不存在。 官方 Cron Triggers 文件頁提到它(說永遠回傳 "scheduled"),但型別定義裡沒有,runtime 上也是 undefined。不要用它。
② noRetry() 存在於型別和 runtime,但沒有出現在官方的 Scheduled Handler API 參考頁上。 它只在 Cron Triggers 頁被間接提到。
③ ctx 和 fetch handler 的完全一樣(第 3 篇那七個成員),包括 waitUntil —— 而且這裡的 waitUntil 是有效的(不像第 14 篇的 DO)。
noRetry() 與失敗的觀測
Section titled “noRetry() 與失敗的觀測”$ curl -s ".../cdn-cgi/handler/scheduled?format=json" # 呼叫了 noRetry(){"outcome":"ok","noRetry":true}
$ curl -s ".../cdn-cgi/handler/scheduled?format=json" # handler 拋出例外{"outcome":"exception","noRetry":false}?format=json 的輸出就是 dashboard 上 Cron Events 表格會記錄的那個結果。本機可以直接驗證你的錯誤處理有沒有正確反映成 exception。
官方對狀態記錄的說法:
「The first
ctx.waitUntilto fail will be observed and recorded as the status in the Cron Trigger Past Events table. Otherwise, it will be reported as a success.」
也就是說 waitUntil 裡的失敗也會被記錄成失敗 —— 這和 fetch handler 不同。
⚠️ cron 失敗之後會不會自動重試,官方文件完全沒有說明。 唯一和重試有關的痕跡就是
noRetry。不要假設有重試機制 —— 需要保證執行就自己在任務裡做冪等 + 補償,或改用 Queues / Workflows。
限制 —— 以及一個文件矛盾
Section titled “限制 —— 以及一個文件矛盾”| 項目 | Free | Paid |
|---|---|---|
| Cron Trigger 數量 | 5 | 250 |
| CPU / 次 | 10 ms | 30 秒(間隔 < 1 小時)/ 15 分鐘(間隔 ≥ 1 小時) |
| Wall clock | 15 分鐘 | 15 分鐘 |
| 設定變更生效 | 最多 15 分鐘 | 最多 15 分鐘 |
| Dashboard 事件紀錄 | 最近 100 次 | 最近 100 次 |
⚠️ 文件矛盾:Cron Triggers 頁寫「maximum number of Cron Triggers per Worker」,而 limits 頁的欄位標題是「Number of Cron Triggers per account」。只有 per-account 的數字有被公布,所以保守假設是帳號層級。
那個依間隔而變的 CPU 預算很有意思,值得單獨記住:
- 間隔 < 1 小時(例如每 5 分鐘)→ 30 秒 CPU
- 間隔 ≥ 1 小時(例如每天)→ 15 分鐘 CPU
所以「每小時跑一次」和「每 59 分鐘跑一次」的 CPU 預算差了 30 倍。重運算的任務要注意排程間隔。
15 分鐘的 wall clock 是硬上限,和第 17 篇的 alarm 一樣。長任務要自己分批。
🎯 Cron vs Alarm vs Workflows
Section titled “🎯 Cron vs Alarm vs Workflows”這是本篇最重要的判斷。
| Cron Trigger | DO Alarm(第 17 篇) | Workflows(第 21 篇) | |
|---|---|---|---|
| 粒度 | 全域,每個 Worker 一組 | 每個實體各自一份 | 每個 instance |
| 排程方式 | cron 運算式 | 任意時間點 | step.sleep |
| 找出「誰到期了」 | 要自己掃 | 不用掃 | 不適用 |
| 重試 | 未文件化 | 6 次然後靜默丟棄 | 每個 step 可設定 |
| 狀態 | 無 | DO storage | durable |
| Wall clock | 15 分鐘 | 15 分鐘 | 可睡一年 |
判準只有一句話:
需要「掃一遍找出誰到期了」就是設計錯了 —— 那應該是 alarm。
// ❌ Cron every minute, scanning for expired links.// Chapter 09: rows_read IS the bill. This scans the whole table forever.async scheduled(controller, env) { const expired = await env.DB .prepare("SELECT * FROM links WHERE expires_at < ?").bind(Date.now()).all(); for (const l of expired.results) await expire(env, l);}
// ✅ Each link arms its own alarm at creation time (chapter 17).this.ctx.storage.setAlarm(expiresAt);Cron 真正適合的只有三類事情:
- 全域的、不屬於任何單一實體的工作 —— 對帳、清理孤兒資料、產生全站報表。
- 外部系統的輪詢 —— 每小時去某個 API 拉一次資料。
- 觸發長流程 —— cron 只負責啟動一個 Workflow(第 21 篇),本身不做事。
順帶一提,Workflows 自 2026-06-02 起可以自己設
schedules(第 21 篇),所以第 3 類的很多場景現在可以完全不用 cron。
Green Compute
Section titled “Green Compute”帳號層級設定(Workers & Pages → Account details → Compute Setting):
「With Green Compute enabled, your Cron Triggers will only run on Cloudflare points of presence that are located in data centers that are powered purely by renewable energy.」
只影響 Cron Trigger,不影響一般請求。代價是可用的資料中心變少(可能有排程延遲),好處是碳足跡。
完整程式碼:
examples/ch20-cron/
cd examples/ch20-cron && npm install && npm run devB=localhost:8787curl -s "$B/clear"
# 正確的觸發方式curl -s "$B/cdn-cgi/handler/scheduled?format=json"curl -s "$B/cdn-cgi/handler/scheduled?cron=0+15+1+*+*&format=json"curl -s "$B/cdn-cgi/handler/scheduled?cron=*%2F3+*+*+*+*&time=1745856238000&format=json"
# 舊方式已經沒了curl -s -o /dev/null -w "%{http_code}\n" "$B/__scheduled" # 404
# controller 的真實形狀curl -s "$B/runs"
# noRetry 與失敗的觀測curl -s "$B/noretry?on=1" && curl -s "$B/cdn-cgi/handler/scheduled?format=json"curl -s "$B/noretry?on=0" && curl -s "$B/throw" && curl -s "$B/cdn-cgi/handler/scheduled?format=json"練習:不帶 ?cron= 觸發一次,看 /runs 裡的 branch 是什麼 —— 那就是「本機測試時 switch 全部落到 default」的樣子。
接進 LinkForge
Section titled “接進 LinkForge”前面幾篇已經把大部分排程需求分配掉了:
| 需求 | 用什麼 | 為什麼不用 cron |
|---|---|---|
| 點擊 flush | DO alarm(第 17 篇) | per-link,cron 要掃全表 |
| 連結過期 | DO alarm(第 17 篇) | 同上 |
| 清理 stale WebSocket | DO alarm(第 17 篇) | per-dashboard |
| Webhook 重試 | Queues(第 19 篇) | 有內建重試與 DLQ |
所以 LinkForge 只有三個真正需要 cron 的任務。
{ "triggers": { "crons": [ "*/5 * * * *", // KV/D1 reconciliation "0 * * * *", // queue backlog + cost checks "0 3 * * MON" // weekly report kickoff ] }}async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { const started = Date.now(); let outcome = "ok";
try { switch (controller.cron) { case "*/5 * * * *": await reconcileKvProjection(env); break; case "0 * * * *": await checkBacklogAndCost(env); break; case "0 3 * * MON": await env.WEEKLY_REPORT.create(); break; // chapter 21 default: // Local testing without ?cron= lands here. Make it loud. console.log(JSON.stringify({ event: "cron_unknown", cron: controller.cron })); } } catch (err) { outcome = "error"; throw err; // so the Cron Events table records it } finally { console.log(JSON.stringify({ event: "cron_run", cron: controller.cron, scheduledTime: controller.scheduledTime, driftMs: started - controller.scheduledTime, // how late did it actually fire durationMs: Date.now() - started, outcome, v: env.VERSION.id, // chapter 04 })); }}四個決策:
① default 分支要出聲。 因為本機測試不帶 ?cron= 時 controller.cron 是空字串,靜默的 default 會讓你以為任務跑了。
② 記錄 driftMs。 started - controller.scheduledTime 就是實際觸發比排程時間晚了多久。這是唯一能觀察排程健康度的指標 —— 官方沒有承諾任何精度。
③ 錯誤要 rethrow。 不然 Cron Events 表會記成成功。
④ 週報只負責啟動 Workflow,不做事。 因為 15 分鐘的 wall clock 撐不住一個要跑幾百個租戶的報表,而 Workflow 可以(第 21 篇)。
對帳任務(唯一真正需要「掃一遍」的)
Section titled “對帳任務(唯一真正需要「掃一遍」的)”async function reconcileKvProjection(env: Env): Promise<void> { // Chapter 08: KV is a projection of D1, written with waitUntil, so it can // drift. This is the compensation. const since = Date.now() - 10 * 60_000; const rows = await env.DB .prepare("SELECT tenant_id, slug, url, is_active FROM links WHERE updated_at > ? LIMIT 500") .bind(since) .all<LinkRow>();
// Bulk get: 100 keys = ONE operation (chapter 08). for (let i = 0; i < rows.results.length; i += 100) { const chunk = rows.results.slice(i, i + 100); const current = await env.LINKS.get(chunk.map((r) => `link:${r.slug}`), "text"); for (const r of chunk) { if (current.get(`link:${r.slug}`) !== r.url) { await env.LINKS.put(`link:${r.slug}`, r.url, { metadata: toMeta(r) }); } } }}這個任務符合「cron 該做的事」的定義:它是全域的、不屬於任何單一實體、而且天生就是要掃一遍。
注意兩個前面篇章的應用:WHERE updated_at > ? + LIMIT 500 控制 rows_read(第 9 篇),以及 bulk get 讓 100 個 key 只算一次操作(第 8 篇)。
本篇交付物:三個 cron 任務、統一的 driftMs / outcome 結構化 log、對帳邏輯,以及一條「新增排程需求時先問『這能不能用 alarm』」的 review 規則。
① 用 --test-scheduled 或 /__scheduled
都沒了。實測 /__scheduled 回 404。用 /cdn-cgi/handler/scheduled。
② 本機測試不帶 ?cron=
controller.cron 是空字串,switch 全部落到 default。
③ 用 controller.type
不存在。型別和 runtime 都沒有。
④ 星期用 0–6
Cloudflare 是 1 = 星期日 到 7 = 星期六。
⑤ 假設 cron 失敗會自動重試
官方完全沒有文件化。自己做冪等或改用 Queues。
⑥ 忘記 waitUntil 的失敗會被記成失敗
和 fetch handler 不同。
⑦ 沒注意 CPU 預算隨間隔改變
間隔 ≥ 1 小時才有 15 分鐘 CPU,否則只有 30 秒。
⑧ 用 cron 掃表找「誰到期了」
那應該是 DO alarm。rows_read 就是帳單。
⑨ 期待任何排程精度
沒有承諾。記錄 driftMs 自己觀察。
⑩ 忘記設定變更要 15 分鐘才生效
改完立刻測會看不到效果。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- 本機測試用
/cdn-cgi/handler/scheduled,而且一定要帶?cron=—— 否則controller.cron是空字串。 - **需要「掃一遍找出誰到期了」就是設計錯了。**那是 DO alarm 的工作。
- **cron 沒有文件化的重試機制。**要保證執行就自己做冪等,或交給 Queues / Workflows。
下一篇:21. Workflows:durable execution —— 可以睡一年的多步驟流程。而且 waiting 狀態的 instance 不計入併發配額,所以可以同時有數百萬個在睡覺。