Background Task และ Webhook กับ Functions
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Database Webhook ทำให้การเปลี่ยนแปลง row ใน Postgres เรียก Edge Function ได้อัตโนมัติ และ EdgeRuntime.waitUntil ทำให้ function นั้นตอบกลับได้ทันทีในขณะที่งานที่ช้ากว่ายังทำต่อในพื้นหลัง
EdgeRuntime.waitUntil พิสูจน์ด้วย timestamp จริง
หัวข้อที่มีชื่อว่า “EdgeRuntime.waitUntil พิสูจน์ด้วย timestamp จริง”EdgeRuntime.waitUntil(promise) ทำเครื่องหมาย promise ว่าเป็น background task ที่ยังรันต่อหลังจาก function ส่ง response ไปแล้ว — instance ของ function จะยังอยู่จนกว่า promise นั้นจะ settle โดยไม่ทำให้ HTTP response ต้องรอ การพิสูจน์ต้องเห็น การไม่ใส่ await ชัดเจน และต้องโชว์ว่า response กลับไปจริงก่อนงานพื้นหลังจะเสร็จ ไม่ใช่แค่อ้างลอย ๆ
declare const EdgeRuntime: { waitUntil: (promise: Promise<unknown>) => void }
async function slowBackgroundWork() { console.log(`[bg] started at ${new Date().toISOString()}`) await new Promise((resolve) => setTimeout(resolve, 3000)) console.log(`[bg] finished at ${new Date().toISOString()}`)}
Deno.serve((_req) => { console.log(`[req] handler entered at ${new Date().toISOString()}`) EdgeRuntime.waitUntil(slowBackgroundWork()) console.log(`[req] returning response at ${new Date().toISOString()}`) return new Response(JSON.stringify({ accepted: true }), { headers: { 'Content-Type': 'application/json' }, })})$ curl -w "\ntime_total=%{time_total}s\n" "$FUNCTIONS_URL/edge-waituntil" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"accepted":true}time_total=0.059900sEdgeRuntime เป็น global จริงตอน runtime ที่ Edge Runtime เองเป็นคนให้มา ไม่ใช่ Deno เปล่า ๆ — deno check เองไม่รู้จักมัน จึงต้องมี declare const EdgeRuntime ที่เห็นข้างบน (.d.ts ทางการที่ publish ไว้ที่ jsr:@supabase/functions-js/edge-runtime.d.ts ก็มีอยู่เหมือนกัน แต่จะ merge เข้าโปรเจกต์ได้สะอาดเฉพาะที่ตั้ง type workflow เต็มรูปแบบของ Supabase Functions ไว้แล้ว การ declare แบบง่าย ๆ ในเครื่องคือวิธีแก้ที่ใช้งานได้จริงสำหรับการ type-check แบบ standalone) response กลับมาใน 60ms ทั้งที่ background task sleep ไว้ 3 วินาที — และ log ของ function เอง ที่อ่านตรงจาก local stack ที่กำลังรันอยู่ โชว์ชัดว่าแต่ละอย่างเกิดขึ้นตอนไหนจริง ๆ
[Info] [req] handler entered at 2026-09-29T04:35:46.823Z[Info] [bg] started at 2026-09-29T04:35:46.830Z[Info] [req] returning response at 2026-09-29T04:35:46.830Z[Info] [bg] finished at 2026-09-29T04:35:49.831Zresponse ออกไปตอน 46.830Z ส่วน background task เพิ่งเสร็จตอน 49.831Z — สามวินาที หลังจาก ผู้เรียกได้คำตอบไปแล้ว กลไกทั้งหมดขึ้นอยู่กับรายละเอียดเดียว: slowBackgroundWork() ถูกเรียกโดยไม่ await แล้วส่ง promise ที่มันคืนมาเข้า waitUntil แทน — ถ้า await มันตรง ๆ response จะรอสามวินาทีเท่ากันเป๊ะ ทำลายจุดประสงค์ทั้งหมดไปเลย
Database Webhooks: ให้ฐานข้อมูลเรียกออกไปเอง
หัวข้อที่มีชื่อว่า “Database Webhooks: ให้ฐานข้อมูลเรียกออกไปเอง”Edge Function ทุกตัวก่อนหน้านี้ถูกเรียกโดย client ตรง ๆ ด้วย supabase.functions.invoke() หรือ curl งานจริง ๆ มักควรเกิดขึ้นเพราะ การเปลี่ยนแปลงในฐานข้อมูล แทน — row ที่โผล่ขึ้นมาใน table ไม่ใช่ client ที่จำได้ว่าต้องถาม Database Webhook คือ Postgres trigger ที่สร้างบน extension pg_net ที่ยิง HTTP request ทุกครั้งที่ insert/update/delete ที่เลือกไว้เกิดขึ้น พิสูจน์แบบ end-to-end กับ table ที่ namespace ไว้จริง
-- supabase/migrations/9002_edge_events_webhook.sqlcreate extension if not exists pg_net with schema extensions;
create table public.edge_events ( id bigint generated always as identity primary key, kind text not null, created_at timestamptz not null default now());
create trigger "edge_events_webhook"after insert on public.edge_eventsfor each rowexecute function supabase_functions.http_request( 'http://supabase_kong_probe:8000/functions/v1/edge-webhook-receiver', 'POST', '{"Content-Type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0","apikey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"}', '{}', '5000');function ที่รับแค่ log สิ่งที่ถูกส่งมา
Deno.serve(async (req) => { const payload = await req.json() console.log(`[webhook] received type=${payload.type} table=${payload.table} record=${JSON.stringify(payload.record)}`) return new Response(JSON.stringify({ received: true }), { headers: { 'Content-Type': 'application/json' }, })})insert จริงหนึ่งครั้ง แล้วเห็นทั้งเชนทำงานจริง — trigger, audit trail ของ pg_net เอง, async HTTP response, และ log line ของ function ที่โชว์ shape ของ payload ที่ได้รับจริง
$ psql -c "insert into public.edge_events (kind) values ('edge_test_event') returning id, created_at;" id | created_at----+------------------------------- 1 | 2026-09-29 04:36:11.418738+00
$ psql -c "select id, hook_table_id, hook_name, request_id from supabase_functions.hooks order by id desc limit 1;" id | hook_table_id | hook_name | request_id----+---------------+---------------------+------------ 1 | 21111 | edge_events_webhook | 1
$ psql -c "select id, status_code, content::text from net._http_response order by id desc limit 1;" id | status_code | content----+-------------+------------------- 1 | 200 | {"received":true}
# from the function's own log:[Info] [webhook] received type=INSERT table=edge_events record={"id":1,"kind":"edge_test_event","created_at":"2026-09-29T04:36:11.418738+00:00"}shape ของ payload ที่ Database Webhook ส่งคือ { type, table, record, schema } — type เป็น INSERT/UPDATE/DELETE, record คือ row ใหม่เป็น JSON ตรงกับ shape WebhookPayload ที่ function ที่รับควร declare ไว้
flowchart LR insertRow["insert into edge_events"] --> webhook["Database Webhook (pg_net, async)"] webhook --> edge["edge-webhook-receiver"] edge --> respond["Immediate response: 200 received"] edge -->|"EdgeRuntime.waitUntil"| bg["Background task (e.g. slow follow-up work)"]
pg_net เป็น asynchronous — พิสูจน์ด้วยเวลา ไม่ใช่แค่อ้างลอย ๆ
หัวข้อที่มีชื่อว่า “pg_net เป็น asynchronous — พิสูจน์ด้วยเวลา ไม่ใช่แค่อ้างลอย ๆ”เหตุผลทั้งหมดที่ทำให้ Database Webhook ปลอดภัยพอจะแปะไว้กับ write path ได้ คือ pg_net ไม่เคยทำให้ statement ที่ trigger มันต้องรอ HTTP response เลย พิสูจน์ด้วยการจับเวลา insert เองด้วย \timing ของ psql ไม่ใช่จับเวลา webhook call
$ psql -c "\timing on" -c "insert into public.edge_events (kind) values ('edge_timing_test') returning id;" id---- 2INSERT 0 1Time: 2.767 ms2.767 มิลลิวินาที สำหรับ insert ที่จะ return — ไม่พอเลยสำหรับ HTTP round trip เต็ม ๆ ไปยัง edge-webhook-receiver ผ่าน Kong และ Edge Runtime (ตัวเลข cold-start จากบทแรกบน stack เดียวกันนี้คือ 60-90ms) pg_net แค่ enqueue request แล้ว return การควบคุมกลับให้ผู้เรียกทันที HTTP call จริงเกิดขึ้นบน background worker ของ pg_net เอง และผลลัพธ์ไปโผล่ใน net._http_response ทีหลัง อ่านหลังจากนั้นตามที่โชว์ข้างบน ไม่ใช่สิ่งที่ insert รอ
Scheduled functions ด้วย pg_cron
หัวข้อที่มีชื่อว่า “Scheduled functions ด้วย pg_cron”pg_cron รัน SQL ตามตารางเวลาจากในตัว Postgres — ไม่ได้ติดตั้งมาโดย default บน local stack นี้ (select * from pg_extension ไม่เจอจนกว่าจะเปิดใช้) พิสูจน์ด้วยการเปิดใช้จริง ตั้ง schedule 1 นาทีจริง แล้วรอให้มันทำงานจริง ไม่ใช่แค่เชื่อ schedule string
select cron.schedule('edge_ping_cron', '* * * * *', $$select 1$$);$ psql -c "select jobid, runid, status, start_time, end_time from cron.job_run_details order by start_time desc limit 1;" jobid | runid | status | start_time | end_time-------+-------+-----------+--------------------------------+-------------------------------- 1 | 1 | succeeded | 2026-09-29 04:37:00.022241+00 | 2026-09-29 04:37:00.023384+00การรันนี้ตกลงที่วินาที :00 ของนาทีถัดไป succeeded และเสร็จในเวลาประมาณ 1 มิลลิวินาที เป็นหลักฐานจริงว่า job ทำงานตามตารางเวลาจริง ไม่ใช่แค่ cron.schedule() return โดยไม่ error การเรียก Edge Function ตามตารางเวลา (แทนที่ select 1) ก็คือการเรียก supabase_functions.http_request(...) เดียวกับที่ trigger ของ webhook ใช้ข้างบน แค่ถูกเรียกโดย cron.schedule แทนที่จะเป็น table trigger การทดสอบ job ถูก unschedule ทันทีหลังพิสูจน์เสร็จ (select cron.unschedule('edge_ping_cron')) — ดูสรุปท้ายโมดูลนี้ว่าอะไรถูกทิ้งไว้ให้รัน กับอะไรถูกเก็บกวาดแล้ว
Idempotency: Database Webhooks ไม่ retry, Auth Hooks retry
หัวข้อที่มีชื่อว่า “Idempotency: Database Webhooks ไม่ retry, Auth Hooks retry”สอง feature ของ Supabase ที่เรียก HTTP endpoint จากในตัว Postgres มีพฤติกรรม retry ที่ต่างกัน — ควรแม่นยำเรื่องนี้ เพราะโค้ดที่ AI สร้าง (และเอกสารที่อ่านผ่าน ๆ) สับสนสองเรื่องนี้ได้ง่าย
- Auth Hooks (HTTP hook ที่ตั้งค่าสำหรับ event เช่น “Customize Access Token Claims”) retry อัตโนมัติ: “On a retry-able error, such as an error with a 429 or 503 status code, HTTP Hooks will attempt up to three retries with a back-off of two seconds. We have a time budget of 5s for the entire webhook invocation, including retry requests” (เอกสาร Auth Hooks)
- Database Webhooks (กลไก trigger ที่ใช้
pg_netที่พิสูจน์ไว้ข้างบน) ไม่มี retry ในตัว — HTTP call ที่พังหรือช้าไม่ถูก retry อัตโนมัติโดยแพลตฟอร์ม คำแนะนำในคู่มือ debugging เองก็บอกให้ querynet._http_responseหา failure เอง ไม่ใช่ “รอ retry” (pg_net troubleshooting)
ผลจริงในทางปฏิบัติ: function ที่รับ Database Webhook ต้องเขียนแบบ idempotent เอง — ปลอดภัยที่จะประมวลผล event เชิงตรรกะเดียวกันซ้ำสองครั้ง เพราะไม่มีอะไรในแพลตฟอร์มการันตี exactly-once delivery และต่างจาก Auth Hooks ตรงที่ไม่มีอะไร retry failure ให้อัตโนมัติด้วย pattern ที่ใช้ได้ผลบ่อยคือ idempotency key (primary key ของ row เอง หรือ request_id จาก supabase_functions.hooks ตาราง audit ที่โชว์ไว้ข้างบน) ที่ตรวจก่อนจะทำอะไรที่มี side effect
พฤติกรรมตอน shutdown
หัวข้อที่มีชื่อว่า “พฤติกรรมตอน shutdown”เอกสารอธิบายกลไกที่เกี่ยวข้องกันสองอย่างที่คอร์สนี้ไม่ได้ reproduce ในเครื่อง บอกไว้ตรง ๆ ดีกว่าแกล้งทำให้เกิด: isolate ที่ใกล้ wall-clock limit จะยิง event beforeunload ก่อนจะ shutdown ให้ function บันทึก state หรือ log progress ได้
addEventListener('beforeunload', (ev) => { console.log('Function will be shutdown due to', ev.detail?.reason)})และ payload ของ shutdown เองมี reason ("WallClockTime" เป็นค่าหนึ่งที่มีเอกสารระบุไว้) พร้อม resource usage ตอน shutdown (เอกสาร shutdown reasons) พยายาม reproduce แบบนี้ในเครื่องด้วยการหยุด process supabase functions serve ของโมดูลนี้เอง แต่ ไม่ trigger listener นี้เลย — การ kill process ของ CLI ทำให้ container ของ local Edge Runtime ทั้งตัวถูกรื้อทิ้งทันที ไม่ใช่การ retire isolate หนึ่งตัวอย่างนุ่มนวลตอนใกล้ wall-clock limit ซึ่งจะเกิดขึ้นจริงเฉพาะกับ function ที่ deploy แล้วใช้งานหนักมานานเท่านั้น กลไกและ shape ของ payload มาจากเอกสารข้างบน ไม่ใช่สิ่งที่วัดได้จริงในเครื่อง
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI ถูกขอให้ “เรียก pg_net ใน trigger แล้วตรวจว่า webhook สำเร็จก่อนจะ return” แล้วได้โค้ดนี้มา
🐛 หา bug ในโค้ดนี้
create or replace function public.edge_notify_and_check()returns triggerlanguage plpgsqlas $$declarereq_id bigint;resp record;beginselect net.http_post( 'http://supabase_kong_probe:8000/functions/v1/edge-webhook-receiver', to_jsonb(new)) into req_id;
-- Check the response right away, in the same transactionselect * into resp from net._http_response where id = req_id;
if resp.status_code is null or resp.status_code >= 400 then raise exception 'webhook failed';end if;
return new;end;$$;เฉลย
โค้ดนี้เข้าใจผิดเรื่องพฤติกรรม “asynchronous” ที่พิสูจน์ไว้ก่อนหน้าในบทนี้พอดี: net.http_post() return request_id ทันที โดยแค่ enqueue HTTP call ไว้เท่านั้น — request จริงอาจยังไม่ถูกส่งด้วยซ้ำ นับประสาอะไรกับเสร็จ ณ ตอนที่บรรทัดถัดไปรัน select ... from net._http_response where id = req_id ในตัว trigger เดียวกันแทบทุกครั้งจะไม่เจอ row เลย (response ยังไม่มาถึง) resp.status_code is null จึงเป็นจริงแทบทุกครั้ง แล้ว trigger ก็ raise 'webhook failed' และ rollback การ insert — แม้ webhook call นั้นจะสำเร็จสมบูรณ์ก็ตาม แค่มาถึงช้ากว่าที่โค้ดนี้ตรวจไปไม่กี่มิลลิวินาที วิธีแก้ไม่ใช่ “รอนานขึ้น” (การ block trigger ด้วย network I/O ทำลายจุดประสงค์ทั้งหมดของการที่ pg_net เป็น async พิสูจน์ไว้ข้างบนด้วยเวลา insert แค่ 2.767ms) — แต่คือการไม่ตรวจแบบ synchronous เลย ปล่อยให้ insert commit ไปแล้วค่อย poll net._http_response ทีหลังแบบ out-of-band (เช่น pg_cron job แยกต่างหาก) หรือตามที่ section idempotency ของบทนี้พูดถึง ออกแบบ function ที่รับให้ retriable ได้อย่างปลอดภัย และเลิกพยายามสังเกตความสำเร็จในตัว transaction เดียวกับที่ trigger มันไปเลย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”กลไกทั้งสองในบทนี้มีไว้ให้ส่วนหนึ่งของระบบพูดว่า “ทำต่อไปเลย ฉันไม่จำเป็นต้องรู้ผลลัพธ์ตอนนี้” — waitUntil พูดแบบนั้นเกี่ยวกับงานที่ยังรันอยู่ ในฟังก์ชันเดียวกัน ส่วน trigger pg_net ของ Database Webhook พูดแบบนั้นเกี่ยวกับงานที่ Postgres เอง สั่งไปแล้วไม่รอ ทั้งสองแบบ “ผู้เรียกได้ response แล้ว” กับ “งานที่ถูกสั่งเสร็จจริงแล้ว” กลายเป็นสองเหตุการณ์ที่แยกกันจริง ๆ ในเวลา พิสูจน์ตรง ๆ ในบทนี้ด้วย timestamp จริง (waitUntil) และ insert ที่เร็วต่ำกว่า 3ms คู่กับ request ที่ใช้เวลาเป็นสิบมิลลิวินาทีกว่าจะเสร็จจริง (pg_net) bug ใน SpotTheBug คือสิ่งที่เกิดขึ้นพอดีเมื่อโค้ดถูกเขียนราวกับสองเหตุการณ์นี้ยังเป็นอันเดียวกันอยู่: ตรวจหาผลลัพธ์ที่ ตามการออกแบบ ยังไม่มีอยู่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”EdgeRuntime.waitUntilทำให้ function ตอบเร็วในขณะที่งานจริงยังทำต่อ — แต่ต้องแลกกับการที่ผู้เรียกไม่มีทางรู้ว่างานพื้นหลังนั้นสำเร็จจริงไหม นอกจากการออกแบบของ function เองจะเปิดเผยเรื่องนี้แยกต่างหาก (webhook ตามมาอีกที, row สถานะที่ client poll)- การออกแบบ async ของ
pg_netทำให้ webhook ไม่เคย block การเขียนที่ trigger มัน — แต่ต้องแลกกับการไม่มี retry ในตัวหรือการยืนยันแบบ synchronous พิสูจน์และอ้างอิงไว้ข้างบน — function ที่รับ และอะไรก็ตามที่สนใจว่ามันรันสำเร็จไหม ต้องถูกสร้างโดยสมมติว่าเป็น delivery แบบ at-most-once แบบ best-effort pg_cronรัน SQL จริงตามตารางเวลาโดยไม่ต้องมี infrastructure ภายนอกเลย — แต่ต้องแลกกับการอยู่ในตัว Postgres ล้วน ๆ: cron job ที่ต้องเรียกออกไปอินเทอร์เน็ตยังต้องผ่านpg_net/Edge Function อยู่ดี รับพฤติกรรม async ไม่มี retry เดียวกับ webhook มาเต็ม ๆ
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
awaitbackground task ในตัวwaitUntilปลอดภัยกว่า แค่ทำให้ response ช้าลงนิดหน่อย” มันไม่ใช่แค่ “ช้าลงนิดหน่อย” — มันทำลายประโยชน์ทั้งหมดไปเลย พิสูจน์ข้างบน:awaitslowBackgroundWork()ตรง ๆ จะทำให้ response รอเต็ม 3 วินาที แทนที่จะเป็น 60ms - “ถ้า
net.http_post()return สำเร็จ แปลว่า webhook call สำเร็จแล้ว” มัน returnrequest_idของ request ที่แค่ enqueue ไว้ — พิสูจน์ข้างบน (และใน SpotTheBug) ว่า HTTP call จริงและ status code ของมันรู้ได้ทีหลังเท่านั้น จากnet._http_response - “Database Webhooks retry call ที่พังอัตโนมัติ เหมือน Auth Hooks” ไม่จริง — พิสูจน์และอ้างอิงไว้ข้างบน: Auth Hooks มี policy retry 3 ครั้ง/budget 5 วินาทีที่มีเอกสารระบุไว้ Database Webhooks ไม่มีเลย และคู่มือ troubleshooting ของแพลตฟอร์มเองก็บอกให้ query
net._http_responseหา failure เอง ไม่ใช่คาดหวัง retry - “
pg_cronต้องมี scheduler ภายนอกหรือ cron daemon บน server” มันรันอยู่ในตัว Postgres ทั้งหมดในฐานะ extension — พิสูจน์ข้างบนด้วย job จริงที่ทำงานตาม schedule* * * * *โดยไม่มี process ภายนอกฐานข้อมูลเกี่ยวข้องเลย