Supabase Edge Functions Background Tasks 與 Cron 排程實戰 | Supabase 完整教學
上一篇《Secrets、環境與部署》我們把第一個 Edge Function 安全地推上了全球邊緣網路。但真實世界的需求往往不只是「收到請求、回一個結果」——很多時候你想在回應使用者之後、還在背景繼續做事(寄通知信、寫日誌、彙整統計),或是根本不靠使用者觸發、而是定時自動執行(每天凌晨產報表、每小時清過期資料)。這一篇就處理這兩類需求:用
EdgeRuntime.waitUntil做「回應後的背景任務」(Background Tasks)、監聽beforeunload事件做收尾,並用pg_cron搭配pg_net(即 Supabase Cron)讓資料庫定時呼叫你的 Edge Function,再談背景與排程都繞不開的冪等設計與執行時間限制。
前言
一句話定義本篇主題:Background Tasks 與 Cron,是讓 Edge Function 從「同步回一個結果」進化到「回應後還能在背景幹活、還能自己定時醒來幹活」的兩種能力——EdgeRuntime.waitUntil(promise) 讓函式在把回應送給客戶端之後,繼續把 promise 跑完(fire-and-forget);pg_cron + pg_net 則讓 PostgreSQL 依 Cron 表達式定時發出 HTTP 請求,主動呼叫你的函式。
先接住上一篇的脈絡。《Secrets、環境與部署》裡我們的函式都是「同步」的:接到請求 → 做完事 → return Response,回應一送出,函式這次的工作就結束了。這對「查天氣」「查文件」這種即時查詢很夠用。但兩類需求它處理不了:
- 第一類,你想加快回應速度。使用者送出一筆訂單,你當然要立刻回「成功」,但「寄確認信、寫 analytics、通知 Slack」這些事讓使用者乾等很浪費時間——它們可以在回應送出後在背景慢慢做。這就是 Background Tasks。
- 第二類,有些工作沒有使用者會去觸發它。「每天凌晨兩點產昨日銷售報表」「每 15 分鐘清一次過期的購物車」——沒有人會在半夜去按按鈕,這需要一個定時器主動叫醒你的函式。這就是 Cron 排程。
用一個生活類比:一間餐廳的服務生(Edge Function),平常是「客人點餐 → 送餐 → 收工」的同步循環。Background Tasks 就像服務生把餐送到桌上(回應客人)後,不站在旁邊等,而是轉身回廚房把「這桌的用餐紀錄登記進系統、把待洗的盤子丟進洗碗機」——這些事在客人已經開動之後才慢慢做,不佔用客人的時間。而 Cron 就像餐廳的營運排程表:不需要任何客人上門,每天打烊後自動盤點庫存、每週一自動叫貨——由一個「時鐘」而非「客人」來驅動。
本篇你會學到:
- Background Tasks:
EdgeRuntime.waitUntil(promise)的運作原理、fire-and-forget 模式、背景任務的獨立錯誤處理,以及本機測試要設的policy = "per_worker" - 生命週期事件:用
beforeunload監聽「函式實例即將被回收」,做最後的收尾 - Cron 排程:用
pg_cron+pg_net(Supabase Cron)讓資料庫定時發 HTTP 呼叫 Edge Function、用 Vault 安全存放金鑰、管理與查詢排程 - 冪等與限制:為什麼排程任務一定要冪等、怎麼做,以及背景/長任務繞不開的 CPU 時間與存活時間限制
本篇聚焦「背景與排程」;
Deno.serve、functions deploy、Secrets 與Deno.env.get()等基礎請見前面兩篇,這裡不再重複。以下 API 名稱與行為細節請以官方文件為準。
核心概念
要把背景任務與排程做對,先建立兩張心智圖:一張是「Background Task 的生命週期」——回應送出後,函式為什麼還活著、活多久;另一張是「Cron 排程呼叫函式的完整流程」——一個 HTTP 請求怎麼從資料庫發出、打到你的函式。
一、Background Task:回應之後,函式還活著
一般函式的生命週期是這樣的:
請求進來 → handler 執行 → return Response → 回應送達客戶端 → 實例可被回收
問題在最後兩步之間:一旦 return Response,任何「還沒 await 完」的非同步工作,理論上可能隨著實例被回收而中斷。 你不能只是「不 await」就期待它會自己跑完——那是不可靠的。
EdgeRuntime.waitUntil(promise) 解決的正是這件事。它等於對執行環境宣告:
「我已經要
return Response了,但請先別回收這個實例,等我交給你的這個promise也跑完再說。」
於是生命週期變成:
請求進來 → handler 執行
→ EdgeRuntime.waitUntil(背景 promise) ← 註冊背景任務
→ return Response ← 客戶端「立刻」收到回應
→ (實例保持存活)背景 promise 繼續跑…
→ 背景 promise 完成 → 實例才可被回收
這就是 fire-and-forget(射後不理) 模式的正確實現:你「射出」背景任務、立刻回應客戶端「不理」它是否完成,但執行環境會幫你把它跑完。關鍵差異在於「回應時間」與「任務完成時間」被解耦了——使用者的等待時間只取決於主要邏輯,背景那些寄信、寫日誌的耗時完全不計入。
二、關鍵術語:waitUntil、beforeunload、per_worker
EdgeRuntime.waitUntil(promise):註冊一個背景 promise,讓實例在回應送出後仍等它完成。這是全域物件EdgeRuntime上的方法(Supabase Edge Runtime 提供),以官方為準。beforeunload事件:當函式實例即將被關閉/回收時觸發的生命週期事件。你可以addEventListener('beforeunload', ...)監聽它,做最後的收尾(記錄狀態、flush 日誌)。注意:這裡不適合再啟動耗時操作,因為留給你的時間很短。policy = "per_worker":supabase/config.toml裡[edge_runtime]的設定。本機 CLI 預設是oneshot——每次請求後立刻終止實例,這會導致背景任務還沒跑完就被切掉,讓你在本機根本觀察不到waitUntil的效果。改成per_worker讓實例在請求間保持存活,才能正確測試背景任務(代價是停用熱重載,改程式碼要手動重啟)。
三、Cron 排程:讓資料庫定時呼叫你的函式
Edge Function 本身沒有內建排程器——它是「被呼叫才執行」的。那「每天凌晨兩點自動執行」怎麼辦?Supabase 的答案是:讓 PostgreSQL 來當那個時鐘,透過兩個擴充功能組合:
pg_cron:PostgreSQL 的定時排程擴充。你用cron.schedule('任務名', 'Cron 表達式', $$SQL$$)登記「到什麼時間就執行這段 SQL」。pg_net:PostgreSQL 的非同步 HTTP 請求擴充。它提供net.http_post(...)讓資料庫能發出 HTTP 請求。
把兩者串起來,就得到「資料庫定時發 HTTP 請求呼叫 Edge Function」的完整鏈路。這套組合在 Supabase 也被包裝為 Supabase Cron 功能(Dashboard 上可視化管理),底層仍是 pg_cron + pg_net,以官方為準。
一張圖看懂整條鏈路:
[pg_cron] 到了 '0 2 * * *'(每日凌晨 2 點)
│ 觸發排程登記的 SQL
▼
[pg_net] net.http_post(
url = 專案URL + '/functions/v1/daily-report',
headers = { Authorization: Bearer <service_role_key> },
body = { ... }
) ← 資料庫「主動」發出 HTTP 請求
│
▼
[Edge Function] daily-report 被喚醒執行
│ 彙整昨日資料、寫入報表、(可搭配 waitUntil 發通知)
▼
[回應] 200 OK → pg_net 收到回應、記錄在 net._http_response
注意這條鏈路的方向:是資料庫主動打進函式,不是函式去輪詢資料庫。這也帶出一個安全點:那段排程 SQL 裡要帶 Authorization 標頭(service_role_key)才能呼叫函式,這把金鑰絕不能明文寫死在 SQL 裡,要用 Vault 加密存放(下一節實作)。
四、Cron 表達式速查
pg_cron 用標準的五欄位 Cron 表達式:
* * * * *
│ │ │ │ └── 星期幾 (0-7,0 和 7 都代表週日)
│ │ │ └──── 月份 (1-12)
│ │ └────── 日期 (1-31)
│ └──────── 小時 (0-23)
└────────── 分鐘 (0-59)
常用範例:
| 表達式 | 意義 |
|---|---|
* * * * * | 每分鐘 |
*/15 * * * * | 每 15 分鐘 |
0 * * * * | 每小時整點 |
0 2 * * * | 每天凌晨 2 點 |
0 9 * * 1 | 每週一早上 9 點 |
0 0 1 * * | 每月 1 號凌晨 |
實作範例
下面用可直接執行的 TypeScript 與 SQL,走一遍「背景任務 → 排程呼叫 → 冪等設計」的完整流程。假設你已完成前面兩篇、已連結遠端專案並設好 Secrets。
一、Background Task:fire-and-forget 寄信與寫日誌
最典型的場景:使用者觸發一個動作,主要邏輯要立刻回應,但寄通知信、寫 analytics 這些慢動作放背景做,不讓使用者乾等。
// supabase/functions/create-order/index.ts
import { createClient } from "npm:@supabase/supabase-js@2"
// 在 handler 外部初始化一次,避免每次請求都重建
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{ auth: { autoRefreshToken: false, persistSession: false } },
)
// 背景任務:務必包在獨立的 try/catch,錯誤不能波及主要回應
async function afterOrderCreated(orderId: string, email: string) {
try {
// 1) 寄確認信(呼叫外部 email 服務)
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "orders@example.com",
to: email,
subject: "您的訂單已成立",
html: `<p>訂單編號 ${orderId} 已成立,感謝購買!</p>`,
}),
})
// 2) 寫一筆 analytics 事件
await supabaseAdmin.from("analytics_events").insert({
event: "order_created",
order_id: orderId,
created_at: new Date().toISOString(),
})
console.log(`背景任務完成:order ${orderId}`)
} catch (err) {
// 背景任務失敗不影響已送出的回應,但要留下紀錄
console.error(`背景任務失敗(order ${orderId}):`, err)
}
}
Deno.serve(async (req: Request) => {
const { email, items } = await req.json()
// === 主要邏輯:使用者需要「等」的部分,要 await ===
const { data: order, error } = await supabaseAdmin
.from("orders")
.insert({ email, items })
.select("id")
.single()
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
// === 背景任務:不 await,交給 waitUntil 在回應後繼續跑 ===
EdgeRuntime.waitUntil(afterOrderCreated(order.id, email))
// 立刻回應:使用者不必等寄信/寫 analytics
return Response.json({ success: true, orderId: order.id }, { status: 201 })
})
三個要點:
- 主要邏輯要
await,背景任務不要await。await afterOrderCreated(...)會讓使用者等它跑完(就失去意義了);正確做法是把它交給EdgeRuntime.waitUntil(...)。 - 背景任務內部要有獨立的
try/catch。回應已經送出去了,背景任務丟出例外沒有人接得住——若不自己 catch,可能變成 unhandled rejection。 - 在 handler 外部初始化
supabaseAdmin:模組層級只建立一次,冷啟動後重複使用,省資源。
如果要同時射出多個背景任務,用 Promise.all 包起來一次交給 waitUntil:
EdgeRuntime.waitUntil(
Promise.all([
sendNotificationEmail(order.id, email),
trackAnalytics(order.id),
notifySlack(`新訂單 ${order.id}`),
]).catch((err) => console.error("背景批次任務有失敗:", err)),
)
二、beforeunload:實例被回收前的收尾
有時你想在「函式實例即將關閉」時做最後一件事——例如記錄「這個實例處理了幾筆請求」,或把還沒 flush 的緩衝寫出去。監聽 beforeunload:
// supabase/functions/worker/index.ts
let handledCount = 0
// 監聽「實例即將被回收」的生命週期事件
addEventListener("beforeunload", (ev) => {
// 注意:這裡時間很短,不要啟動新的耗時操作
console.log(`實例即將關閉,本次共處理 ${handledCount} 筆請求`)
// 若有支援,ev.detail 可能帶關閉原因(如 CPU/wall time 超限),以官方為準
})
// 順手也接住未處理的背景 promise 錯誤,避免整個實例被拖垮
addEventListener("unhandledrejection", (ev) => {
console.error("未處理的 Promise rejection:", ev.reason)
ev.preventDefault()
})
Deno.serve(async (_req: Request) => {
handledCount++
return Response.json({ ok: true, handledCount })
})
beforeunload 的定位是「收尾」而非「幹活」——把它當成程式關機前的最後一句話,記錄狀態、留線索,別在裡面塞新的重活。
三、本機測試:設定 per_worker
前面兩個範例若你在本機用 supabase functions serve 測,會發現背景任務常常「還沒跑完就沒下文」——因為本機預設 oneshot,每次請求後立刻終止實例。改 config.toml:
# supabase/config.toml
[edge_runtime]
# 預設是 oneshot(每次請求後終止實例,會切掉背景任務)
# 改成 per_worker 讓實例在請求間存活,才能正確測 waitUntil / beforeunload
policy = "per_worker"
# 改完設定後重啟本機服務
supabase stop
supabase start
supabase functions serve create-order
提醒:
per_worker會停用熱重載,之後改函式程式碼要手動重啟serve。這是本機觀察背景任務的必要取捨;正式環境不受此設定影響。
四、Cron 排程:pg_cron + pg_net 定時呼叫函式
現在做第二類需求:每天凌晨兩點自動產昨日報表。先寫好被排程呼叫的函式,再到資料庫登記排程。
先看被呼叫的函式。它是「內部端點」,會用 --no-verify-jwt 部署(因為沒有使用者 JWT),所以要在函式內部自己驗證請求真的來自排程:
// supabase/functions/daily-report/index.ts
import { createClient } from "npm:@supabase/supabase-js@2"
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
const supabaseAdmin = createClient(Deno.env.get("SUPABASE_URL")!, serviceRoleKey)
Deno.serve(async (req: Request) => {
// 內部端點自我驗證:確認 Authorization 帶的是 service_role key
// (排程 SQL 會帶這個標頭,外部亂打的請求帶不出來)
const auth = req.headers.get("Authorization")
if (auth !== `Bearer ${serviceRoleKey}`) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await req.json().catch(() => ({}))
// 排程會帶 runDate 進來,作為「這次要處理哪一天」的冪等鍵
const runDate: string = body.runDate ??
new Date(Date.now() - 86400000).toISOString().split("T")[0]
// === 冪等檢查:這一天的報表若已產過,直接略過(見下一節詳解)===
const { data: existing } = await supabaseAdmin
.from("daily_reports")
.select("id")
.eq("report_date", runDate)
.maybeSingle()
if (existing) {
return Response.json({ skipped: true, reason: "已產過", runDate })
}
// 彙整當日資料
const start = `${runDate}T00:00:00.000Z`
const end = `${runDate}T23:59:59.999Z`
const { data: orders, error } = await supabaseAdmin
.from("orders")
.select("amount")
.gte("created_at", start)
.lte("created_at", end)
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
const total = (orders ?? []).reduce((s, o) => s + (o.amount ?? 0), 0)
// 用 upsert 寫入,即使同一天被重複觸發也只會有一筆
await supabaseAdmin.from("daily_reports").upsert(
{ report_date: runDate, order_count: orders?.length ?? 0, revenue: total },
{ onConflict: "report_date" },
)
// 產完報表後,發通知這種慢動作可以丟背景
EdgeRuntime.waitUntil(
fetch(Deno.env.get("SLACK_WEBHOOK_URL")!, {
method: "POST",
body: JSON.stringify({ text: `${runDate} 報表已產:${total} 元` }),
}).catch((e) => console.error("Slack 通知失敗:", e)),
)
return Response.json({ success: true, runDate, revenue: total })
})
部署(公開端點,關掉平台層 JWT):
supabase functions deploy daily-report --no-verify-jwt
接著到資料庫登記排程。先在 Dashboard → Database → Extensions 啟用 pg_cron 與 pg_net,然後用 SQL Editor 執行。金鑰用 Vault 加密存放,不硬編碼進 SQL:
-- 步驟 1:把專案 URL 與 service_role key 加密存入 Vault(各做一次)
select vault.create_secret(
'https://your-project-id.supabase.co',
'project_url'
);
select vault.create_secret(
'your-service-role-key',
'service_role_key'
);
-- 步驟 2:登記排程——每天凌晨 2 點呼叫 daily-report 函式
select cron.schedule(
'daily-report-job', -- 排程名稱(唯一)
'0 2 * * *', -- Cron 表達式:每日 02:00
$$
select net.http_post(
-- 從 Vault 取出 URL,不明文寫在這裡
url := (select decrypted_secret from vault.decrypted_secrets
where name = 'project_url') || '/functions/v1/daily-report',
headers := jsonb_build_object(
'Content-Type', 'application/json',
-- 從 Vault 取出 service_role key 組 Authorization
'Authorization', 'Bearer ' || (select decrypted_secret
from vault.decrypted_secrets where name = 'service_role_key')
),
-- 把「要處理的日期」傳進去,當作冪等鍵
body := jsonb_build_object(
'runDate', to_char(now() - interval '1 day', 'YYYY-MM-DD')
)
);
$$
);
管理與查詢排程:
-- 列出所有排程
select jobid, jobname, schedule, active from cron.job;
-- 查最近的執行歷史(成功/失敗、回傳訊息)
select jobid, status, return_message, start_time
from cron.job_run_details
order by start_time desc
limit 20;
-- 停用/刪除某個排程
select cron.unschedule('daily-report-job');
net.http_post是非同步的:它把請求排入佇列後立刻返回,不會等 Edge Function 執行完。函式的實際回應會被記錄在net._http_response(欄位與保留期以官方為準)。所以「排程有沒有真的呼叫成功、函式回了什麼」要去查cron.job_run_details與函式的 Dashboard 日誌,不是看cron.schedule當下的輸出。
五、冪等設計:讓重複執行也不出錯
上面的 daily-report 已經埋了冪等的兩道保險,這裡把它講透。冪等的目標是:同一個排程任務被觸發兩次以上時,結果和只跑一次一樣。你必須假設「重複呼叫一定會發生」——網路逾時後 pg_net 可能重送、你手動補跑、排程不小心重疊,都會造成重複。
常見三種冪等手法:
// 手法 A:先查再做——用唯一鍵判斷「這個週期是否已處理」
const { data: done } = await supabaseAdmin
.from("daily_reports").select("id").eq("report_date", runDate).maybeSingle()
if (done) return Response.json({ skipped: true }) // 已處理,直接略過
// 手法 B:用 upsert 取代 insert,靠唯一約束自動去重
// 前提:daily_reports.report_date 有 UNIQUE 約束
await supabaseAdmin.from("daily_reports").upsert(
{ report_date: runDate, revenue: total },
{ onConflict: "report_date" }, // 同一天重複寫入 → 更新而非新增一筆
)
// 手法 C:把冪等鍵交給下游 API 去去重(以呼叫外部服務為例)
await fetch("https://api.some-payment.com/charge", {
method: "POST",
headers: {
"Idempotency-Key": `charge-${runDate}`, // 同一鍵,下游只會扣一次
"Content-Type": "application/json",
},
body: JSON.stringify({ amount: total }),
})
實務上常把「先查再做」與「upsert」兩者一起用:先查可以在重複時快速略過、省掉整段運算;upsert 則是最後寫入的保險絲,即使前面的檢查因競態沒擋住,唯一約束也能兜底。核心心法一句話:假設你的排程任務一定會有被重複呼叫的一天,然後讓它在那天也不出錯。
常見錯誤與最佳實踐
坑一:把該背景做的事寫成 await,讓使用者乾等。
最常見的誤用是 await sendEmail(...) 之後才 return——寄信可能花好幾秒,使用者就得等那幾秒才看到「成功」。正確做法:主要邏輯(使用者真的要等結果的部分)才 await;寄信、寫 analytics、通知這類 fire-and-forget 工作交給 EdgeRuntime.waitUntil(promise),回應時間只取決於主要邏輯。反過來的坑是「以為不 await 就會自己跑完」——不註冊進 waitUntil 的話,回應送出後任務可能隨實例回收而中斷,也是錯的。
坑二:背景任務沒有獨立的 try/catch,錯誤變成 unhandled rejection。
回應已經送出去了,背景任務丟例外沒有人接。正確做法:每個背景任務內部自己 try/catch(或在 waitUntil 的 promise 尾巴掛 .catch()),把錯誤 console.error 出來留線索;並在模組頂層 addEventListener('unhandledrejection', ...) 兜底,避免一個漏網的 rejection 影響整個實例。
坑三:本機測背景任務沒設 per_worker,誤以為 waitUntil 壞了。
本機 CLI 預設 oneshot,請求一結束就終止實例,背景任務被硬切,於是你看到「日誌只印一半」就以為程式有 bug。正確做法:本機測背景任務/beforeunload 前,先在 supabase/config.toml 設 [edge_runtime] policy = "per_worker" 再 supabase stop && supabase start;記得它會停用熱重載。
坑四:排程任務不冪等,重跑一次就出事。
「寄帳單」跑兩次寄兩封、「扣款」跑兩次扣兩次、「產報表」跑兩次多一筆髒資料——因為 pg_net 重送或手動補跑造成重複執行。正確做法:用唯一鍵「先查再做」快速略過已處理的週期、寫入用 upsert(onConflict) 兜底、對外部 API 帶 Idempotency-Key。設計時就假設「一定會被重複呼叫」。
坑五:把 service_role key 明文寫死在 cron.schedule 的 SQL 裡。
排程 SQL 會存進 cron.job 表,明文金鑰等於攤在能查表的人眼前。正確做法:用 vault.create_secret() 把 project_url 與 service_role_key 加密存入,排程 SQL 裡用 select decrypted_secret from vault.decrypted_secrets where name = '...' 動態取出組標頭;被排程呼叫的函式雖然 --no-verify-jwt,仍要在函式內部自己驗證(比對 Authorization 或自訂密鑰),別讓內部端點裸奔。
坑六:把長任務硬塞進一次 waitUntil,撞上存活/CPU 時間限制。
背景任務仍受函式整體限制:存活時間(wall clock)免費約 150 秒、付費約 400 秒,且CPU 時間(不含非同步 I/O 等待)每次請求上限約 2 秒(數字以官方為準)。想在背景「一次跑完十萬筆」的任務會被強制回收、只做了一半。正確做法:把長任務拆成多次排程呼叫——每次只處理一批(用游標/分頁),靠冪等與進度記錄接續;或改用專門的佇列機制,而不是靠單次 waitUntil 硬撐。
坑七:以為 net.http_post 會等函式跑完、把它的回傳當成函式結果。
pg_net 是非同步的,net.http_post 排入請求就返回,cron.schedule 當下看不到函式的真正回應。正確做法:驗證排程成效要查 cron.job_run_details(排程有沒有觸發)、net._http_response(HTTP 回應)與函式的 Dashboard 日誌(函式內部發生什麼),三者合看。
最佳實踐總結:
- 回應與背景解耦:主要邏輯
await,慢動作用EdgeRuntime.waitUntil,回應時間只看主要邏輯 - 背景任務要自我防護:獨立
try/catch+unhandledrejection兜底,錯誤留console.error - 本機測背景先設
per_worker:否則背景任務被oneshot切掉,會誤判成 bug - 排程一律冪等:先查再做 +
upsert+Idempotency-Key,假設一定會被重複呼叫 - 金鑰走 Vault:
cron.schedule裡別明文金鑰,用vault.decrypted_secrets動態取;內部端點仍自我驗證 - 長任務要分批:受存活/CPU 時間限制,拆成多次排程呼叫而非一次
waitUntil硬跑 - 驗證看三處:
cron.job_run_details+net._http_response+ 函式日誌 - 不確定的 API 以官方為準:
EdgeRuntime.waitUntil、beforeunload細節、pg_net/Vault 表結構、時間限制數字都可能調整,正式決策前查官方文件
小結
這是 Supabase 系列教學的第 033 篇,延續 SB-4 運算與 AI 篇章。上一篇《Secrets、環境與部署》我們把第一個 Edge Function 安全地推上正式環境;這一篇補上兩種讓函式「更主動」的能力——回應後在背景幹活的 Background Tasks、以及自己定時醒來幹活的 Cron 排程。
核心觀念濃縮成一句話:EdgeRuntime.waitUntil(promise) 讓函式在 return Response 之後仍把背景 promise 跑完(fire-and-forget,回應時間與任務完成時間解耦),背景任務要有獨立錯誤處理、本機測要設 per_worker、實例回收前可用 beforeunload 收尾;pg_cron + pg_net(Supabase Cron)讓資料庫依 Cron 表達式定時發 HTTP 呼叫你的函式,金鑰用 Vault 加密存放,而所有排程任務都必須做到冪等、並顧及 CPU 與存活時間的限制。
- Background Tasks:
EdgeRuntime.waitUntil回應後續跑、獨立try/catch、per_worker本機測試、beforeunload收尾 - Cron 排程:
pg_cron定時 +pg_net發 HTTP、Vault 存金鑰、cron.job_run_details查歷史 - 冪等三招:先查再做、
upsert(onConflict)、Idempotency-Key,假設一定會被重複呼叫 - 限制底線:存活時間(150/400 秒)與 CPU 時間(約 2 秒)有上限,長任務要拆成多次排程
你的函式現在能在回應後繼續幹活、也能定時自動執行了。但還有一類即時互動需求沒解決:伺服器要「一段一段」把內容推給客戶端(像 ChatGPT 那樣逐字吐字),或要接住外部服務主動打進來的事件(Stripe 付款成功、GitHub push)。下一篇《串流回應與 Webhooks 實戰》,我們就來處理這兩件事:用 ReadableStream 做串流回應(Server-Sent Events)、用 --no-verify-jwt + 簽章驗證安全地接 Webhook。運算篇章繼續前進,下一篇見。