Supabase Edge Functions Background Tasks 與 Cron 排程實戰 | Supabase 完整教學

2026/10/22
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 })
})

三個要點:

  1. 主要邏輯要 await,背景任務不要 await。await afterOrderCreated(...) 會讓使用者等它跑完(就失去意義了);正確做法是把它交給 EdgeRuntime.waitUntil(...)。
  2. 背景任務內部要有獨立的 try/catch。回應已經送出去了,背景任務丟出例外沒有人接得住——若不自己 catch,可能變成 unhandled rejection。
  3. 在 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。運算篇章繼續前進,下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →