ผู้ใช้คลิกปุ่ม คำขอค้างไปสิบวินาทีแห่งความเงียบงัน จากนั้นพวกเขาก็คลิกปุ่มสำรอง (fallback button) ตอนนี้จึงมีงานสองอย่างกำลังรันอยู่ภายใต้ความตั้งใจเดียว ผลที่ตามมาคือเกิด side effects ซ้ำซ้อน, การเก็บเงินเบิ้ล และข้อมูลเละเทะจนทำให้คุณต้องเสียเวลาทั้งบ่าย
นี่ไม่ใช่บั๊กที่ frontend การปิดปุ่มหรือการใช้ debounce timer ใน React จะไม่ช่วยคุณ เพราะคำขอแรกถูกส่งออกไปแล้ว (in flight) เพียงแต่เครือข่ายแค่กลืนคำตอบหายไปเฉยๆ หาก backend ของคุณมองว่าทุกคำขอที่เข้ามาคือคำสั่งใหม่ การทำ retry จะกลายเป็นภาระทันที คุณจำเป็นต้องแก้ไขเรื่องนี้ในการออกแบบ API และ schema ของฐานข้อมูลของคุณ
ทางออกเริ่มต้นด้วยการแยกโครงสร้างอย่างง่าย
แยก Job ออกจาก Attempt
ให้คิดว่า Job คือบันทึกที่คงทนของสิ่งที่ผู้ใช้ต้องการ มันจะเก็บข้อมูลเจ้าของ (owner), พารามิเตอร์, ผู้ให้บริการเป้าหมาย (target provider) และความตั้งใจที่แน่ชัด ส่วน Attempt คือความพยายามแต่ละครั้งในการทำให้ความตั้งใจนั้นสำเร็จ
ลองนึกภาพร้านพิมพ์งาน คุณส่งไฟล์ให้พวกเขาแล้วพวกเขาให้ตั๋วหมายเลข #45 แก่คุณ ตั๋วนั้นคือ Job ร้านลองใช้เครื่องพิมพ์อิงค์เจ็ทแล้วกระดาษติด นั่นคือ attempt แรก จากนั้นพวกเขาเปลี่ยนไปใช้เครื่องพิมพ์เลเซอร์ นั่นคือ attempt ที่สอง ตลอดกระบวนการ ตั๋วหมายเลข #45 จะไม่มีวันเปลี่ยนแปลง หากร้านออกตั๋วใบใหม่ทุกครั้งที่เปลี่ยนเครื่องพิมพ์ คุณจะต้องจ่ายเงินสามเท่าและได้รับสำเนาที่ไม่ต้องการถึงสามชุด
ฐานข้อมูลของคุณควรสะท้อนภาพนี้ ตารางหนึ่งเก็บ Jobs อีกตารางหนึ่งเก็บ Attempts แถวของ Job จะคงที่เสมอ ในขณะที่ Attempts จะสะสมเพิ่มขึ้นภายใต้ Job นั้น
การแยกส่วนแบบนี้ช่วยให้คุณควบคุมได้ และยังเป็นที่สำหรับแนบ idempotency key ที่สามารถอยู่รอดได้แม้เครือข่ายจะติดขัด
กำหนดให้มี Idempotency Key ในทุก Job
ทุก POST request ที่สร้าง Job จะต้องพก idempotency key ที่ไม่ซ้ำกันมาด้วย คีย์นี้เป็นของผู้ใช้ ไม่ใช่ของ session ให้รวม owner ID และ key เข้าด้วยกัน จากนั้นบังคับใช้ unique database constraint บนสองคอลัมน์นี้
ทำไมต้องใช้ database constraint? เพราะการเช็คการมีอยู่ของข้อมูลในโค้ดแอปพลิเคชันก่อนการ insert คือการเปิดช่องให้เกิด race condition ที่พร้อมจะเกิดขึ้นได้ทุกเมื่อ คำขอที่เหมือนกันสองคำขออาจหลุดรอดผ่านช่องว่างระดับไมโครวินาทีเดียวกันได้ จงปล่อยให้ฐานข้อมูลเป็นผู้บังคับใช้กฎ หากผู้ใช้ส่ง owner ID และ key เดิมซ้ำเป็นครั้งที่สอง คำขอที่สองจะติด unique violation และคุณก็แค่ส่ง job เดิมที่มีอยู่กลับไป ทั้งสองคำขอจะได้ job ID เดียวกัน และจะไม่มีการเริ่มงานซ้ำซ้อน
จงเข้มงวดเรื่องขอบเขต (scope) หากมีคนใช้ key เดิมแต่เปลี่ยนข้อมูล input payload ให้ส่ง error conflict กลับไป Idempotency key ต้องผูกติดกับความตั้งใจที่แน่ชัด ไม่ใช่แค่ผูกกับตัวผู้ใช้ การใช้ key เดิมแต่ input ต่างกันหมายความว่า client กำลังสับสน และระบบของคุณควรปฏิเสธมันแทนที่จะพยายามเดาใจ
ปกป้องการเปลี่ยนสถานะ (State Transitions)
Attempt คือการเปลี่ยนสถานะ ไม่ใช่ job ใหม่ API ของคุณต้องปฏิเสธการสร้าง attempt ใหม่ หาก attempt ก่อนหน้ายังคงค้างอยู่ในสถานะ starting หรือ unknown
สาเหตุคือเรื่อง timeout เมื่อคำขอไปยัง provider หมดเวลา ฝั่ง client จะเห็นว่าล้มเหลว แต่กระบวนการฝั่ง server อาจยังทำงานอยู่ Cluster ของ GPU อาจยังคงประมวลผลคำขอ inference ของคุณอยู่ หรือ container อาจยังคงเขียนข้อมูลลง blob storage หากคุณทำเครื่องหมาย attempt ที่ timeout ว่าล้มเหลวแล้วสั่งรัน attempt ที่สองทันที คุณกำลังเดิมพันกับ side effects ที่จะเกิดขึ้นซ้ำซ้อน
จงปฏิบัติกับ timeout ในฐานะสถานะที่ไม่ทราบแน่ชัด (unknown state) ไม่ใช่สถานะที่ล้มเหลว (failed state) ให้บล็อก attempt ใหม่จนกว่า attempt ก่อนหน้าจะถึงสถานะสุดท้าย (terminal state) หรือถูกยกเลิกโดยกระบวนการภายนอก (out-of-band process) อย่างชัดเจน การหยุดรอแบบนี้อาจดูน่าอึดอัด เพราะมันบังคับให้ผู้ใช้ต้องรอ แต่มันช่วยป้องกันความโกลาหลจากการที่มี worker สองตัวพยายามแก้ไขทรัพยากรปลายทางตัวเดียวกัน
แก้ไขปัญหาการแข่งกัน (Races) ด้วย Compare-and-Swap
ปัญหาที่ยากที่สุดจะปรากฏขึ้นเมื่อมีหลาย attempt ทำงานเสร็จพร้อมกัน สมมติว่าระบบของคุณส่ง attempt แรกไปยัง provider หลัก หลังจากเงียบหายไปสิบวินาที มันจึงส่ง attempt ที่สองไปยัง fallback คราวนี้ทั้งสอง attempt ทำงานเสร็จแล้ว คุณไม่สามารถปล่อยให้ทั้งคู่เขียนผลลัพธ์ลงใน job row เดียวกันได้
จงใช้ตรรกะ compare-and-swap โดยเพิ่มเลข version เข้าไปใน job row เมื่อ attempt ทำงานเสร็จ ให้รันการ update พร้อมเงื่อนไขดังนี้:
- version ปัจจุบันต้องตรงกับค่าที่ attempt นั้นอ่านมาตอนเริ่มต้น
- ต้องไม่มี attempt อื่นที่จองช่องสำหรับผลลัพธ์ (result slot) ไปก่อนแล้ว
- หากผ่านทั้งสองเงื่อนไข ให้เขียนผลลัพธ์และเพิ่มค่า version ขึ้น
ในเชิง SQL มันจะมีหน้าตาเหมือนคำสั่ง update ที่มี WHERE id = $1 AND version = $2 AND completed_by IS NULL หากการ update คืนค่ากลับมาเป็นศูนย์แถว แสดงว่ามี attempt อื่นชนะไปก่อนแล้ว คำขอที่มาทีหลังต้องถูกเพิกเฉย ทิ้งผลลัพธ์ของมันไป อย่าพยายามรวมข้อมูล (merge) อย่าพยายามต่อท้าย (append) จงโยนงานนั้นทิ้งไป ผลลัพธ์ที่มาทีหลังแล้วไปเขียนทับผู้ชนะคนก่อนคือการทำให้ข้อมูลเสียหาย (data corruption) และวิธีที่ปลอดภัยเพียงอย่างเดียวคือการทิ้งมันไป
สิ่งนี้จัดการการเสร็จสิ้นแบบย้อนลำดับได้อย่างราบรื่น ความพยายาม A เริ่มก่อนแต่ส่งผลกลับมาหลังจากผ่านไป 30 วินาที ความพยายาม B เริ่มทีหลังแต่ส่งผลกลับมาหลังจากผ่านไป 5 วินาที ความพยายาม B ชนะการทำ compare-and-swap การอัปเดตของความพยายาม A จึงไม่กระทบแถวใดๆ เลย ระบบของคุณจะบันทึกเหตุการณ์ race นี้ไว้ ละทิ้ง payload ที่ล้าสมัย และดำเนินการต่อไป
ทดสอบจุดแตกหัก (Breakpoints)
คุณจะไม่พบข้อผิดพลาดเหล่านี้จากการทดสอบแบบ happy-path ชุดการทดสอบของคุณจำเป็นต้องมุ่งเป้าไปที่จุดเปราะบางเหล่านั้น
- จำลองการดับเบิลคลิก การส่ง POST request พร้อมกันสองครั้งด้วย idempotency key เดียวกันจะต้องคืนค่า job ID ที่เหมือนกัน
- ส่ง key เดิมแต่ใช้ input ที่ไม่ตรงกัน คาดหวังการตอบกลับแบบ conflict ระบบต้องไม่คืนค่า job เดิมที่มีอยู่เงียบๆ หากพารามิเตอร์แตกต่างกัน
- กระตุ้นให้เกิด timeout ตรวจสอบว่า job ตกอยู่ในสถานะที่ไม่ทราบแน่ชัด (unknown state) ไม่ใช่สถานะล้มเหลว (failed state) และระบบต้องระงับความพยายามอื่นๆ จนกว่าความคลุมเครือจะหมดไป
- บังคับให้ความพยายามสองครั้งเสร็จสิ้นในลำดับย้อนกลับ ยืนยันว่ารายการที่ส่งกลับมาเป็นลำดับที่สองจะเป็นฝ่ายแพ้ แม้ว่ารายการแรกที่เริ่มออกไปจะเป็นผู้ให้บริการหลัก (primary provider) อย่างเป็นทางการก็ตาม
การทดสอบเหล่านี้ไม่ใช่ความหรูหราสำหรับกรณีขอบเขต (edge-case) แต่มันคือข้อตกลง (contract) ที่ API ของคุณทำไว้กับส่วนที่เหลือของระบบ
ตรวจสอบเจตนาของผู้ให้บริการก่อนทำการ Failover
หากคุณใช้การตั้งค่าแบบหลายผู้ให้บริการ (multi-provider) คุณอาจถูกชักจูงให้มองว่าโมเดล AI ต่างๆ เป็นเพียงช่องที่สามารถสลับเปลี่ยนกันได้ พวกมันใช้ code path เดียวกัน, HTTP client ตัวเดียวกัน และ JSON schema เดียวกัน แต่นั่นไม่ได้หมายความว่าพวกมันจะมีพฤติกรรมเหมือนกัน
โมเดลหนึ่งอาจสร้าง key ระดับบน (top-level key) ขึ้นมาเองจากการหลอน (hallucinate) อีกโมเดลหนึ่งอาจเพิกเฉยต่อรูปแบบ system prompt ของคุณ การตรวจสอบ schema จะตรวจพบข้อผิดพลาดทางไวยากรณ์ (syntax errors) แต่จะยอมรับการตอบกลับที่ business logic ของคุณไม่สามารถตีความได้ ผู้ให้บริการอาจส่ง JSON ที่ถูกต้องตามรูปแบบมาให้ แต่กลับทำงานผิดพลาดกับ prompt template ของคุณ
รันการทดสอบเฉพาะสำหรับผู้ให้บริการแต่ละรายก่อนที่จะอนุญาตให้มีการสลับโมเดลโดยอัตโนมัติ ยืนยันว่าโมเดลสำรอง (fallback model) เคารพโครงสร้างเอาต์พุตของคุณจริงๆ เมื่อใช้ค่า temperature ต่ำ ตรวจสอบว่า prompt ของคุณแสดงผลได้อย่างถูกต้องผ่าน tokenizer ของผู้ให้บริการรายนั้น ทดสอบแบบครบวงจร (full round trip) ด้วยข้อมูลนำเข้าจริง การทำ automatic failover จะปลอดภัยก็ต่อเมื่อคุณพิสูจน์ได้แล้วว่าโมเดลสำรองนั้นมีข้อตกลงในการทำงาน (operational contract) แบบเดียวกัน
รักษาหนึ่งงานต่อหนึ่งเจตนา
เส้นทาง fallback นั้นดี แต่การเพิ่มจำนวน fallback อย่างไม่สามารถควบคุมได้คือข้อผิดพลาด (bug) ทุกเลเยอร์ใน stack ของคุณจำเป็นต้องประเมินว่าเคยเห็นงานที่ตรงกันเป๊ะๆ นี้มาแล้วหรือยัง ทั้ง load balancer, API handler, ฐานข้อมูล และ worker จะต้องเคารพอัตลักษณ์ (identity) เดียวกันทั้งหมด
สร้างระบบของคุณเพื่อให้การลองใหม่ (retries) และการ fallback ปรากฏเป็นความพยายามใหม่ภายใต้ job ที่เสถียรเพียงอันเดียว ล็อก job นั้นด้วย idempotency key ที่รองรับโดยฐานข้อมูล คุ้มครองการเปลี่ยนผ่าน (transitions) ให้ความพยายามต่างๆ แข่งขันกัน และปล่อยให้มีเพียงหนึ่งเดียวเท่านั้นที่ชนะ นี่คือวิธีที่คุณจะป้องกันไม่ให้การคลิกเพียงครั้งเดียวของผู้ใช้ กลายเป็นการต้องมานั่งล้างข้อมูลตลอดทั้งวันหยุดสุดสัปดาห์
