งานถูกบันทึกแล้ว แต่ Agent ยังส่งงานต่อไม่ถึง? ออกแบบ Transactional Outbox

วิศวกรกับผู้ช่วย AI ตรวจบัญชีรายการที่ Commit แล้ว ขณะที่ประตูผู้ให้บริการภายนอกยังรอใบตอบรับ
วิศวกรกับผู้ช่วย AI ตรวจบัญชีรายการที่ Commit แล้ว ขณะที่ประตูผู้ให้บริการภายนอกยังรอใบตอบรับ
ภาพอธิบายแนวคิด Transactional Outbox: ข้อมูลถูกบันทึกในระบบแล้ว แต่การรับจากผู้ให้บริการภายนอกยังไม่ยืนยัน; เป็นภาพประกอบ ไม่ใช่ผลทดสอบ ต้นทาง · ภาพเต็ม

คำตอบสั้น: เมื่อการเปลี่ยนข้อมูลในฐานข้อมูลต้องตามด้วยการแจ้งระบบอื่น ให้บันทึกข้อมูลหลักและ “เจตนาที่จะส่ง” ในฐานข้อมูลเดียวกันและธุรกรรมเดียว แล้วให้ตัวส่งแยกออกไปทำงานหลัง commit บทเรียนนี้ใช้ SQLite กับ mock provider และข้อมูลรีวิววิดีโอสังเคราะห์เท่านั้น ไม่เรียก API จริง

แผนภาพสองเลนเปรียบเทียบช่องว่าง Dual Write ก่อนใช้ Outbox กับการบันทึกข้อมูลธุรกิจและเจตนารอส่งใน SQLite ก่อน Commit และค่อยส่งภายหลัง
ภาพอธิบาย: ก่อนใช้ Outbox โปรเซสอาจล้มหลังบันทึกข้อมูลธุรกิจแต่ก่อนเรียกปลายทาง; Outbox บันทึกข้อมูลธุรกิจกับเจตนารอส่งร่วมกัน แต่ไม่ได้ยืนยันว่าปลายทางรับแล้ว ต้นทาง · ภาพเต็ม

ผลลัพธ์การเรียนรู้

เมื่อจบบท คุณจะออกแบบ transactional outbox ขนาดเล็ก ระบุข้อมูลหลักกับ event ที่ต้องส่ง ใช้ identity คงที่ป้องกันเจตนาซ้ำ แยกการ commit ในเครื่องออกจากการส่งภายนอก และหยุดเมื่อผลส่งคลุมเครือ งานนี้เป็นสะพานจากการใช้ LLM สนทนาไปสู่การสร้างเครื่องมือที่ตรวจสอบผลได้: โมเดลเสนอ tool intent ส่วน adapter ที่จะต่อจริงต้องตรวจสิทธิ์ก่อน โค้ดตัวอย่างตรวจข้อมูลสองฟิลด์และควบคุมสถานะตามกติกาที่เขียนไว้

ปัญหา: dual write มีช่องว่าง

สมมติ Agent ช่วยทีมตรวจวิดีโอ ผู้ตรวจเลือก “อนุมัติให้แก้ต่อ” ระบบต้องบันทึกผลและแจ้งระบบงานอีกแห่ง หากบันทึกฐานข้อมูลก่อนแล้วโปรเซสหยุดก่อนส่ง ระบบหลักแสดงว่าอนุมัติแล้วแต่ไม่มีใครได้รับแจ้ง หากส่งแจ้งเตือนก่อนแล้วฐานข้อมูล rollback ผู้รับเห็นงานที่ระบบต้นทางไม่เคยยืนยัน นี่คือ dual write: การเปลี่ยนแปลงสองระบบที่ไม่ได้อยู่ใต้ transaction เดียวกัน

การเรียก API ระหว่างเปิด transaction ก็ไม่แก้ปัญหา เครือข่ายที่ช้าอาจล็อกฐานข้อมูล และคำตอบที่หายไปไม่ได้บอกว่าปลายทางรับงานหรือยัง Outbox ทำให้คำรับประกันแคบลงและชัดขึ้น: ฐานข้อมูลจะบันทึกการเปลี่ยนแปลงหลักพร้อมรายการงานส่งต่อ หรือไม่บันทึกทั้งคู่ การส่งจริงเป็นขั้นต่อไปซึ่งยังมีความไม่แน่นอนของตัวเอง

แผนภาพขอบเขต SQLite transaction ครอบคลุม review_requests กับ outbox_events แล้วส่งต่อไป Dispatcher และ Mock Provider หลัง Commit
ภาพอธิบายการบันทึก review_requests และ outbox_events ใน SQLite transaction เดียว; หาก Rollback สำเร็จก่อน Commit จะไม่เหลือทั้งคู่; เป็นภาพอธิบาย ไม่ใช่ผลทดสอบ ต้นทาง · ภาพเต็ม

โครงสร้างข้อมูลและ identity

ตัวอย่างมี review_requests สำหรับผลตัดสิน และ outbox_events สำหรับ intent ที่รอส่ง ทั้งคู่ในไฟล์ SQLite เดียว review_id เป็นตัวตนเชิงธุรกิจ ส่วน event_id สร้างอย่างกำหนดได้จากคีย์นั้น เนื้อหา JSON ถูกจัดรูปแบบ canonical แล้วแฮชด้วย SHA-256 เพื่อผูก ID เข้ากับ payload เดิม เมื่อมีคนใช้ ID เดิมกับเนื้อหาใหม่ ระบบต้องแจ้ง conflict แทนการเขียนทับ

สถานะมีสามค่า: PENDING หมายถึง intent ถูก commit และยังไม่ถูก dispatcher หยิบ, UNKNOWN หมายถึงผลการส่งไม่ชัดหรือถูกทำเครื่องหมายก่อนเริ่มส่ง และ SENT ใช้เมื่อ mock ตอบรับชัดเจนและบันทึก receipt ได้ สถานะนี้เป็นหลักฐานจาก mock ไม่ใช่หลักฐานจากบริการจริง

แผนภาพสถานะจำลอง PENDING ไป UNKNOWN ก่อนเรียก Mock; เมื่อคำตอบหายให้คง UNKNOWN และตรวจ event_id ก่อนระบุ SENT เมื่อมีใบตอบรับ
ภาพจำลอง: PENDING เปลี่ยนเป็น UNKNOWN ก่อนเรียก Mock; ผลไม่ชัดให้คง UNKNOWN จนตรวจ event_id และเปลี่ยนเป็น SENT เมื่อมีใบตอบรับพร้อมบันทึกสถานะสำเร็จ ต้นทาง · ภาพเต็ม

ลำดับการทำงาน

  1. เครื่องมือรับคำขอที่ผ่าน schema และสิทธิ์แล้ว โดยผูกกับ review_id ที่ระบบยืนยัน
  2. เปิด BEGIN IMMEDIATE ตรวจ ID เดิม หาก payload ตรงกันคืนผลเดิม หากขัดกันให้หยุด
  3. แทรกแถวรีวิวและแถว outbox แล้ว commit ทั้งคู่ ถ้าผิดพลาดก่อน commit ให้ rollback
  4. dispatcher อ่านรายการที่ commit แล้วนอก write transaction จากนั้นเปลี่ยนจาก PENDING เป็น UNKNOWN และ commit ก่อนเรียก provider
  5. เมื่อได้คำตอบรับที่ชัดเจน จึงเขียน receipt และเปลี่ยนเป็น SENT; หากผลคลุมเครือให้คง UNKNOWN

fence ก่อนเรียก provider มี tradeoff สำคัญ: ถ้าโปรเซสหยุดหลังบันทึก UNKNOWN แต่ก่อนเรียก provider จะเกิด false unknown ทั้งที่ยังไม่ได้ส่ง ระบบยอมรับความไม่คืบหน้านี้เพื่อไม่ส่งซ้ำโดยเดาเอา ตัวอย่างไม่มีการคืน UNKNOWN เป็น PENDING และไม่มี retry API ผู้ดูแลต้องตรวจสถานะจาก provider ที่เชื่อถือได้และเข้าใจ idempotency ก่อนออกแบบ reconciliation เพิ่ม

เดินดูโค้ด

ใน code/outbox_mock.py ฟังก์ชัน canonical_payload เรียงคีย์ JSON และตัดช่องว่างส่วนเกินก่อนคำนวณ hash จึงให้ digest เดิมสำหรับข้อมูลเชิงโครงสร้างเดียวกัน record_review ใช้ parameterized SQL และ transaction ชัดเจน เพราะการเชื่อมต่อเปิด autocommit=True การ insert แถวหลักกับ outbox จึงเกิดระหว่าง BEGIN/COMMIT เดียวกัน ตัวอย่าง failure injection ทำให้เห็น rollback หลัง insert แรกแต่ก่อน insert ที่สอง

dispatch_one เลือกทีละหนึ่งรายการ แล้ว conditional update จาก PENDING เป็น UNKNOWN ภายใน transaction สั้น ๆ ก่อนเรียก mock จึงไม่มี network I/O ใน transaction การออกแบบสมมติ single dispatcher ไม่ใช่ข้อพิสูจน์สำหรับ worker หลายตัว ไม่มี lease, scheduler, throughput control หรือสัญญาการเรียงลำดับ และเมื่อ SQLite แจ้ง busy ตัวอย่างหยุดแทนการซ่อน retry

mock เก็บ receipt ใน dictionary ของโปรเซสเดียวกัน ถ้า event ID และ hash ตรงกันจะคืน receipt เดิม แต่เมื่อเริ่มโปรเซสใหม่ dictionary ว่าง จึงเป็นภาพประกอบ idempotency เท่านั้น ไม่ใช่ provider key แบบ durable การจำลอง lost response บันทึกว่า mock รับแล้วก่อนโยน TimeoutError เพื่อแสดงกรณีผู้ส่งไม่รู้ผล จากนั้น dispatcher จะไม่แตะ UNKNOWN อีก

จาก LLM ไป deterministic tool

คำขอสังเคราะห์อาจระบุ asset sample-video-7 กับผล approve_edit โดยไม่ส่งเนื้อหาวิดีโอหรือข้อมูลส่วนบุคคล โมเดลเสนอ tool intent ตาม schema เช่นรหัสงานกับผลที่ต้องการ เครื่องมือ deterministic ตรวจว่า review ID อยู่ในขอบเขตสิทธิ์และ decision อยู่ใน enum ที่อนุญาตก่อนเรียก record_review อย่าให้โมเดลเขียน SQL หรือกำหนด event ID เอง เพราะจะข้ามขอบเขตควบคุม

หลังบันทึก ระบบอาจบอกว่า “บันทึกคำขอในเครื่องแล้ว” แต่ยังไม่ควรบอกว่า “ส่งถึงระบบปลายทางแล้ว” จนกว่าจะมี receipt ชัดเจน ความต่างของข้อความนี้ช่วยให้ผู้ใช้ไม่เข้าใจผลเกินหลักฐาน วิดีโอแล็บในหน้านี้ช่วยจำลองการวาง workflow เท่านั้น ไม่ได้เรียก provider หรือสร้าง/ส่งวิดีโอ

ตัวอย่างเดินเหตุการณ์ตั้งแต่รับคำขอ

ลองตามหนึ่งคำขอให้ครบ: ระบบได้รับ review_id=rv-204 และ decision ที่ผ่านการตรวจแล้วว่าเป็น approve_edit เครื่องมือสร้างข้อมูล payload แบบมีรูปแบบตายตัว มีเฉพาะรหัส asset สังเคราะห์กับ decision ตาม schema ของตัวอย่าง ไม่เติมเวลาปัจจุบันเข้า payload เพราะจะทำให้ hash ของการเรียกซ้ำเปลี่ยนไป ไม่รับ SQL จากโมเดล จากนั้นจัด JSON ให้เรียงคีย์และคำนวณ SHA-256 ของเนื้อหานั้น ตัวอย่างกำหนด event_id=review:rv-204 ดังนั้นการเรียกซ้ำด้วยความหมายเดิมจะชี้กลับไปยัง intent เดิม

ภายใน BEGIN IMMEDIATE เครื่องมือค้น review_id ก่อน ถ้าไม่พบจึงเพิ่มแถวธุรกิจและ outbox แล้ว commit ถ้าพบ hash เดิม จะไม่สร้าง intent ที่สอง ถ้าพบ hash ต่างกัน จะหยุดด้วย conflict เพราะคำขอใหม่พยายามใช้ตัวตนเดิมกับเนื้อหาคนละชุด จุดนี้ป้องกันการใช้ retry เพื่อกลบการเปลี่ยนคำสั่งโดยไม่ตั้งใจ แต่ไม่ได้แก้กรณีที่ระบบต้นทางกำหนด ID ผิดตั้งแต่แรก ผู้เรียกต้องส่ง stable identity ที่ถูกต้อง

เมื่อ commit สำเร็จ คำตอบของ producer คือยืนยันว่า “บันทึกในเครื่องแล้ว” เท่านั้น ไม่ได้หมายความว่าระบบวิดีโอหรือผู้ตรวจคนอื่นได้รับแจ้ง จากนั้น dispatcher อ่าน PENDING ที่ commit แล้ว ปิดช่วง transaction และเปลี่ยนสถานะเป็น UNKNOWN พร้อม commit fence ก่อน mock call หาก mock คืน receipt ชัดเจน เครื่องมือเปิด transaction ใหม่เพื่อบันทึก SENT และ receipt หากตอบ timeout สถานะคง UNKNOWN โดยไม่สรุปว่าปลายทางปฏิเสธ

ลองเทียบสาม timeline: (1) insert ทั้งคู่แล้ว commit สำเร็จ—มี intent ให้ dispatcher พบ; (2) insert แถวหลักแล้วเกิด exception ก่อน outbox—ถ้า rollback สำเร็จจะไม่มีแถวของคำขอนี้; (3) fence เป็น UNKNOWN แล้ว mock รับแต่คำตอบหาย—มีความเป็นไปได้ว่าปลายทางทำงานแล้ว แต่ฐานข้อมูลยืนยันได้เพียง UNKNOWN ในกรณีที่สาม การลองส่งด้วย event ID ใหม่เป็นความผิดพลาด เพราะสร้างตัวตนใหม่ให้ผลข้างเคียงเดิมและทำให้ตรวจสอบยากขึ้น

ถ้าระบบตื่นขึ้นมาแล้วพบ UNKNOWN วิธีที่ปลอดภัยต้องอาศัยหลักฐานที่เชื่อถือได้จาก provider เช่นการค้นสถานะด้วย key เดิม หรือการยืนยันกับเจ้าหน้าที่ตามขั้นตอนที่กำหนดไว้ก่อน การตรวจเพียงว่า “ไม่มี receipt ใน SQLite” ไม่ได้แปลว่า provider ไม่ได้รับงาน เพราะ receipt อาจหายระหว่างทาง ตัวอย่างนี้จึงไม่ reset, ไม่ retry และไม่มี endpoint สำหรับ operator; การออกแบบขั้นตอนพิสูจน์ผลกับสิทธิ์ผู้อนุมัติเป็นงานต่อเนื่องที่ต้องระบุแยก อย่าเพิ่มคำสั่งแก้ state แบบทั่วไปให้ Agent เรียกเอง

เครื่องมือควรแยกผลลัพธ์สามชั้นให้ผู้เรียกเห็น: local transaction committed, dispatch outcome unknown, provider accepted with receipt. การรวมทั้งหมดเป็นคำว่า success ทำให้ UI และ log บอกเกินหลักฐาน ในระบบจริง execution trace ควรเชื่อม tool call กับ event ID และ state transition โดยไม่เก็บข้อมูลวิดีโอส่วนตัว ส่วน metric อาจนับอายุของ UNKNOWN เพื่อให้คนตรวจ แต่ไม่ควรตีความเป็น provider outage โดยไม่มีการสืบสาเหตุ

ขอบเขตค่าใช้จ่ายก็แยกชั้นเช่นกัน mock ในตัวอย่างมีเพียงการทำงานบนเครื่อง จึงไม่มีค่าบริการภายนอกให้ประมาณ เมื่อเปลี่ยนไป sandbox อาจมีข้อจำกัดโควตาหรือเก็บข้อมูลทดสอบ และ live อาจมีค่า request, storage หรือค่าแรงในการ reconciliation ทั้งหมดต้องมาจากราคาและข้อมูลวัดจริงของผู้ให้บริการ/ระบบที่เลือก บทความนี้ไม่ได้ให้ตัวเลขหรือ benchmark การคาดเดาราคาเป็นตัวเลขจะทำให้การตัดสินใจดูแม่นยำทั้งที่ไม่มีหลักฐาน

ก่อนรับงานเพิ่ม ให้ตรวจ acceptance ทีละข้อ: ถ้า rollback สำเร็จก่อน commit จะไม่มีแถวของคู่นี้; หาก COMMIT หรือ ROLLBACK ให้ error ให้หยุดตรวจข้อมูลด้วย ID และ hash เดิม อย่าอนุมานว่าไม่มีแถว; replay เดิมไม่เพิ่ม intent; payload conflict หยุดก่อน dispatch; provider acceptance ที่ชัดเจนจึงลง SENT; unknown outcome ไม่ถูกหยิบอัตโนมัติ; และไม่มี transaction ค้างขณะเรียก provider มี test source เก้ากรณีที่เขียนไว้ แต่ยังไม่มีกรณีฉีดความล้มเหลว COMMIT/ROLLBACK หรือทดสอบ dispatcher พร้อมกัน จึงยังไม่ครอบคลุมทุก invariant และไม่ใช่ผลทดสอบที่รันแล้ว ในแพ็กเกจนี้โค้ดและการทดสอบทั้งหมดคงสถานะ NOT RUN

Failure modes

เหตุการณ์ผลในตัวอย่างการตอบสนอง
เกิด error ก่อน commitไม่มีข้อมูลทั้งคู่หลัง rollbackหยุดและคืน error
ID เดิมและ payload เดิมคู่เดิมยังอยู่คืน event ID เดิม
ID เดิมกับ payload ใหม่ข้อมูลเดิมไม่ถูกเขียนทับแจ้ง conflict ก่อนส่ง
provider ตอบรับชัดเจนreceipt และ SENTรายงานเฉพาะผล mock
รับแล้วแต่คำตอบหายUNKNOWN; mock อาจมี receipt ในหน่วยความจำหยุด ไม่ส่งซ้ำอัตโนมัติ
หยุดหลัง fence แต่ก่อน callUNKNOWN แม้ยังไม่ส่งต้องตรวจหลักฐานก่อนออกแบบทางแก้
เขียน SENT ไม่ได้อาจคง UNKNOWNหยุดและตรวจสถานะ ไม่เดาผล

ความปลอดภัยและต้นทุน

โค้ดตัวอย่างนี้ไม่ได้ implement authentication, authorization หรือการตรวจ schema ระดับ API; โค้ดตรวจรูปข้อมูลภายในให้มีเฉพาะ asset และ decision จำกัดรหัสไม่เกิน 80 ตัวอักษรและ decision สองค่า แต่ยังสมมติว่า caller ที่เชื่อถือได้ผ่านการตรวจสิทธิ์แล้ว Adapter ที่จะนำไปใช้ต่อในอนาคตต้องยืนยันตัวตนและสิทธิ์ review ฝั่งเซิร์ฟเวอร์ ตรวจชนิด/enum/ขนาดข้อมูล และรับเฉพาะ intent ที่จำเป็น ก่อนเรียกฟังก์ชันบันทึก บทความอธิบายข้อกำหนดของขอบเขตนั้น ไม่ได้อ้างว่ามีการป้องกันดังกล่าวอยู่ในไฟล์ตัวอย่าง บันทึก execution ID โดยไม่ใส่ secret ใน log; ค่า hash ไม่ใช่การเข้ารหัส หาก payload อ่อนไหวยังต้องจำกัดสิทธิ์ไฟล์ฐานข้อมูลและกำหนด retention การประกอบ SQL จากข้อความ prompt เสี่ยง injection; ใช้ parameterized query ตามตัวอย่าง

ตัวอย่างใช้ standard library และไม่มีค่า API หรือ GPU จึงไม่มีราคาบริการให้คำนวณ หากเลือก sandbox หรือ live ต้องแยกวัด provider calls, latency, retries, storage และต้นทุนการตรวจสอบ UNKNOWN โดยอ้างราคาปัจจุบันของผู้ให้บริการ นอกจากนี้ต้องออกแบบ durable idempotency, authoritative status lookup, recovery operator, concurrency, monitoring และการอนุมัติข้อมูลก่อนใช้งานจริง

Task brief คัดลอกไปปรับใช้

Goal: บันทึกผลรีวิวสังเคราะห์และ intent แจ้งระบบภายนอกอย่าง atomic ในฐานข้อมูลเดียว
Inputs: stable review_id, asset reference ที่ไม่ระบุตัวบุคคล, decision จาก enum
Allowed tools: Python deterministic tool, SQLite local, mock provider; ห้าม network
Output: event_id และสถานะ local commit; แสดง delivered เมื่อมี receipt ชัดเจนเท่านั้น
Acceptance: สองแถว commit/rollback เป็นคู่, replay เดิมไม่เพิ่มแถว, payload ขัดกัน fail ก่อน send, UNKNOWN ไม่ถูกหยิบซ้ำ
Stop: หยุดเมื่อ ID conflict, SQLite error, receipt คลุมเครือ หรือ state transition ไม่ตรง ห้ามสุ่ม ID ใหม่หรือ reset UNKNOWN เอง

แบบฝึกและ rubric

อธิบายว่าทำไม dispatcher ต้องไม่หยิบ UNKNOWN ซ้ำ และเขียนรายการหลักฐานที่ผู้ดูแลต้องมีก่อนคืนงานให้คิว เช่น status lookup ของ provider ที่อ้าง event ID พร้อมคำอธิบายขอบเขต idempotency อย่าเพิ่มปุ่ม retry ที่เปลี่ยนสถานะโดยไม่มีหลักฐาน

ให้คะแนนตนเอง 0–2 ต่อข้อ: แยก local commit กับผลส่ง; ผูก stable ID กับ payload และจัดการ conflict; อธิบาย false UNKNOWN และ lost response; ระบุข้อมูล/สิทธิ์/ต้นทุนที่ยังไม่ทราบ รวม 8 คะแนน ผ่านแบบฝึกเมื่อได้อย่างน้อย 6 และไม่มีข้อใดอ้าง exactly-once หรือ production-ready จาก mock

ทบทวนขั้นตอนด้วย Video Lab

ใช้แล็บวิดีโอร่าง brief สำหรับจำลอง “อนุมัติรีวิวและเตรียมส่ง intent” ระบุเป้าหมาย ข้อมูลสังเคราะห์ ขอบเขตเครื่องมือ เกณฑ์ยอมรับ และจุดหยุดให้ตรงกับบทเรียน แล็บช่วยวาง workflow เท่านั้น ไม่เรียก provider ไม่ส่งวิดีโอ และไม่ใช่การทดสอบการส่งจริง

ทดลองมองขอบเขต Transaction ผ่านโต๊ะงานสามมิติ

ภาพสามมิตินี้เป็น simulation ที่เขียนขึ้นเพื่อสอนแนวคิด กรอบฐานข้อมูลเก็บสองรายการไว้ด้วยกัน ส่วนผู้ให้บริการอยู่คนละขอบเขต กดปุ่มหมุนมุมมองเพื่อเห็นตำแหน่งของข้อมูล การหมุนไม่ได้เรียก Agent หรือส่งงานจริง

SQLite · ธุรกรรมเดียว
review_requests
คำขอตรวจวิดีโอจำลอง
outbox_events
รายการตั้งใจแจ้งผู้ตรวจ
COMMIT → Dispatcher →
Mock Provider
อยู่นอกธุรกรรม SQLite

มุมเริ่มต้น: เห็นธุรกรรมภายในและผู้ให้บริการภายนอกแยกกัน

คำอธิบายภาพแบบข้อความ

ภายใน SQLite มีคำขอกับ outbox หนึ่งคู่ การ commit สำเร็จทำให้คู่นี้มีอยู่พร้อมกัน Dispatcher อ่านภายหลัง แล้วบันทึก UNKNOWN ก่อนเรียกปลายทาง จึงไม่มีช่วงที่เริ่มส่งแล้วฐานข้อมูลยังบอกว่าส่งได้อย่างปลอดภัย ผลตอบรับแน่ชัดเท่านั้นจึงเปลี่ยนเป็น SENT การหมุนภาพเป็นเพียงมุมมอง

เลือกจุดขัดข้อง แล้วแยกสิ่งที่รู้กับสิ่งที่ต้องตรวจ

แบบจำลองแยกนี้ใช้เหตุการณ์ที่กำหนดไว้ล่วงหน้า ไม่มีการทดสอบระบบจริง เลือกหนึ่งกรณีแล้วอ่านสถานะกับสิ่งที่ควรทำต่อ โดยไม่ถือสีเขียวเป็นหลักฐานว่าปลายทางจริงทำงานสำเร็จ

กรณีเริ่มต้น: หาก rollback สำเร็จก่อน commit จะไม่มีข้อมูลคู่นี้ และ producer ไม่ส่งภายนอก

ตัวอย่างนี้ไม่มีปุ่มส่งซ้ำจาก UNKNOWN เพราะต้องตรวจหลักฐานปลายทางก่อน โค้ดกับส่วนโต้ตอบยังไม่ได้รันตรวจพฤติกรรม สถานะ NOT RUN / UI UNKNOWN

Quick Answer

บันทึกข้อมูลหลักกับ outbox intent ใน SQLite transaction เดียว แล้วส่งภายนอกหลัง commit ใช้ identity คงที่ตรวจ replay/conflict และทำเครื่องหมาย UNKNOWN ก่อน provider call เพื่อไม่ replay แบบเดา ตัวอย่างไม่มี reconciliation จึงต้องหยุดเมื่อ UNKNOWN และไม่รับประกัน exactly-once

FAQ

Outbox ทำให้ส่ง exactly-once หรือไม่?

ไม่ ธุรกรรมครอบคลุมเฉพาะ SQLite ส่วน provider อยู่นอก transaction การรับซ้ำและผลไม่ชัดยังต้องพึ่ง idempotency/status semantics กับ reconciliation

ทำไม UNKNOWN อาจค้างทั้งที่ยังไม่ส่ง?

โปรเซสอาจหยุดระหว่าง commit fence กับ provider call เกิด false unknown เพื่อแลกกับการไม่ replay แบบไม่รู้ผล ตัวอย่างไม่ implement วิธีคืนสถานะ

ใช้หลาย dispatcher ได้หรือไม่?

บทเรียนสมมติ single dispatcher และไม่มี lease หรือ concurrency protocol จึงยังสรุปความปลอดภัยหรือ throughput ของหลาย worker ไม่ได้

นำ mock ไปต่อ API จริงได้เลยไหม?

ไม่ได้ ต้องออกแบบ provider idempotency, status lookup, credentials, privacy, recovery, observability และต้นทุนก่อน การเปลี่ยนเป็น live เป็นงานแยก

อ่านต่อใน Hybrid Mind

บทความ การบันทึก receipt ก่อน ACK ใน consumer พูดถึงฝั่งผู้รับข้อความและหน้าต่างที่ ACK สูญหาย ส่วนบทนี้วาง outbox ที่ฝั่ง producer เพื่อผูกการเปลี่ยนแปลงในฐานข้อมูลกับ intent ที่จะส่ง จึงเป็นคนละขอบเขตของปัญหา หากต้องการทบทวน timeout และงบ retry อ่าน Retry Budget กับ Cooperative Deadline; การมี retry budget ช่วยจำกัดความพยายาม แต่ไม่ได้ทำให้ผล provider ที่ UNKNOWN กลายเป็นทราบแน่

แหล่งอ้างอิง

ขอบเขตหลักฐาน: แหล่งอ้างอิงอธิบายแนวคิดทั่วไป ส่วน schema, UNKNOWN ก่อน call, false-unknown tradeoff และ mock เป็นการออกแบบตัวอย่างเฉพาะบทเรียน ไม่ใช่พฤติกรรมที่แหล่งเหล่านั้นรับรอง โค้ดและการทดสอบยังไม่ได้รัน

code/outbox_mock.py · NOT RUN
"""Educational SQLite transactional-outbox demo. Python 3.12+, stdlib only.

The provider is an in-memory mock. This is not a production delivery system.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import sqlite3
import tempfile
import uuid
from pathlib import Path
from typing import Any


class IdentityConflict(ValueError):
    """A stable logical ID was reused for different content."""


class MockProvider:
    """Same-process idempotency illustration; state vanishes on restart."""

    def __init__(self) -> None:
        self.receipts: dict[str, tuple[str, str]] = {}

    def send(self, event_id: str, payload_hash: str, *, lose_response: bool = False) -> str:
        previous = self.receipts.get(event_id)
        if previous and previous[0] != payload_hash:
            raise IdentityConflict("provider key reused with different payload")
        receipt = previous[1] if previous else f"mock-{uuid.uuid4().hex[:12]}"
        self.receipts[event_id] = (payload_hash, receipt)
        if lose_response:
            raise TimeoutError("mock accepted; response was lost")
        return receipt


def connect(path: str | Path) -> sqlite3.Connection:
    db = sqlite3.connect(path, autocommit=True)
    db.execute("PRAGMA foreign_keys = ON")
    db.executescript("""
        CREATE TABLE IF NOT EXISTS review_requests (
            review_id TEXT PRIMARY KEY,
            payload_hash TEXT NOT NULL,
            body_json TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS outbox_events (
            event_id TEXT PRIMARY KEY,
            review_id TEXT NOT NULL UNIQUE REFERENCES review_requests(review_id),
            payload_hash TEXT NOT NULL,
            body_json TEXT NOT NULL,
            state TEXT NOT NULL CHECK(state IN ('PENDING','UNKNOWN','SENT')),
            provider_receipt TEXT
        );
    """)
    return db


def canonical_payload(payload: dict[str, Any]) -> tuple[str, str]:
    if type(payload) is not dict or set(payload) != {"asset", "decision"}:
        raise ValueError("expected exactly asset and decision")
    if not isinstance(payload["asset"], str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", payload["asset"]):
        raise ValueError("asset must be a bounded synthetic reference")
    if payload["decision"] not in ("approve_edit", "request_changes"):
        raise ValueError("unsupported decision")
    body = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
    return body, hashlib.sha256(body.encode("utf-8")).hexdigest()


def record_review(db: sqlite3.Connection, review_id: str, payload: dict[str, Any], *, fail_after_business: bool = False) -> str:
    """Commit business row and notification intent together; never sends."""
    if not isinstance(review_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", review_id):
        raise ValueError("review_id must be a stable bounded identifier")
    body, digest = canonical_payload(payload)
    event_id = f"review:{review_id}"
    db.execute("BEGIN IMMEDIATE")
    try:
        old = db.execute("SELECT payload_hash FROM review_requests WHERE review_id=?", (review_id,)).fetchone()
        if old:
            if old[0] != digest:
                raise IdentityConflict("review ID already binds different content")
            db.execute("ROLLBACK")
            return event_id
        db.execute("INSERT INTO review_requests VALUES (?, ?, ?)", (review_id, digest, body))
        if fail_after_business:
            raise RuntimeError("injected failure before outbox insert")
        db.execute("""INSERT INTO outbox_events
            (event_id,review_id,payload_hash,body_json,state)
            VALUES (?,?,?,?, 'PENDING')""", (event_id, review_id, digest, body))
        db.execute("COMMIT")
        return event_id
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise


def dispatch_one(db: sqlite3.Connection, provider: MockProvider, *, lose_response: bool = False) -> str:
    """One dispatcher invocation; UNKNOWN is a stop fence, never auto-replayed."""
    row = db.execute("SELECT event_id,payload_hash,body_json FROM outbox_events WHERE state='PENDING' ORDER BY event_id LIMIT 1").fetchone()
    if row is None:
        return "NOTHING_PENDING"
    event_id, digest, _body = row  # SELECT has ended; no transaction spans provider I/O.
    db.execute("BEGIN IMMEDIATE")
    try:
        changed = db.execute("UPDATE outbox_events SET state='UNKNOWN' WHERE event_id=? AND payload_hash=? AND state='PENDING'", (event_id, digest)).rowcount
        if changed != 1:
            db.execute("ROLLBACK")
            return "NOTHING_PENDING"
        db.execute("COMMIT")  # Persist uncertainty fence before the provider call.
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise  # Stop before any provider call; do not infer commit outcome.
    try:
        receipt = provider.send(event_id, digest, lose_response=lose_response)
    except Exception:
        return "UNKNOWN"
    db.execute("BEGIN IMMEDIATE")
    try:
        changed = db.execute("UPDATE outbox_events SET state='SENT',provider_receipt=? WHERE event_id=? AND payload_hash=? AND state='UNKNOWN'", (receipt, event_id, digest)).rowcount
        if changed != 1:
            db.execute("ROLLBACK")
            return "UNKNOWN"
        db.execute("COMMIT")
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        return "UNKNOWN"
    return "SENT"


def demo() -> None:
    with tempfile.TemporaryDirectory(prefix="hm-outbox-") as folder:
        db = connect(Path(folder) / "lesson.sqlite3")
        event_id = record_review(db, "synthetic-review-001", {"asset": "sample-video-7", "decision": "approve_edit"})
        print(f"locally_recorded={event_id}")
        print(f"dispatch={dispatch_one(db, MockProvider())}")
        print(f"state={db.execute('SELECT state FROM outbox_events').fetchone()[0]}")
        db.close()


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--demo", action="store_true", help="write a synthetic row to a temporary database")
    if parser.parse_args().demo:
        demo()
    else:
        parser.print_help()
code/test_outbox_mock.py · NOT RUN
"""Authored invariant tests. Status: NOT RUN."""
import sqlite3
import tempfile
import unittest
from pathlib import Path

from outbox_mock import IdentityConflict, MockProvider, connect, dispatch_one, record_review


class OutboxInvariants(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.db = connect(Path(self.temp.name) / "test.sqlite3")
        self.provider = MockProvider()

    def tearDown(self):
        self.db.close()
        self.temp.cleanup()

    def test_business_change_and_intent_commit_as_pair(self):
        record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        self.assertEqual(self.db.execute("SELECT count(*) FROM review_requests").fetchone()[0], 1)
        self.assertEqual(self.db.execute("SELECT state FROM outbox_events").fetchone()[0], "PENDING")

    def test_failure_between_inserts_rolls_back_both(self):
        with self.assertRaises(RuntimeError):
            record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"}, fail_after_business=True)
        self.assertEqual(self.db.execute("SELECT count(*) FROM review_requests").fetchone()[0], 0)
        self.assertEqual(self.db.execute("SELECT count(*) FROM outbox_events").fetchone()[0], 0)

    def test_replay_same_identity_is_stable(self):
        first = record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        second = record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        self.assertEqual(first, second)
        self.assertEqual(self.db.execute("SELECT count(*) FROM outbox_events").fetchone()[0], 1)

    def test_conflicting_identity_fails_before_second_send(self):
        record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        with self.assertRaises(IdentityConflict):
            record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "request_changes"})
        self.assertEqual(self.provider.receipts, {})

    def test_definite_acceptance_records_receipt(self):
        record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        self.assertEqual(dispatch_one(self.db, self.provider), "SENT")
        self.assertTrue(self.db.execute("SELECT provider_receipt FROM outbox_events").fetchone()[0])

    def test_accepted_but_lost_response_stays_unknown_and_is_skipped(self):
        record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        self.assertEqual(dispatch_one(self.db, self.provider, lose_response=True), "UNKNOWN")
        self.assertEqual(self.db.execute("SELECT state FROM outbox_events").fetchone()[0], "UNKNOWN")
        before = dict(self.provider.receipts)
        self.assertEqual(dispatch_one(self.db, self.provider), "NOTHING_PENDING")
        self.assertEqual(self.provider.receipts, before)

    def test_sqlite_transaction_does_not_span_provider_call(self):
        class InspectingProvider(MockProvider):
            def send(inner, *args, **kwargs):
                self.assertFalse(self.db.in_transaction)
                return super().send(*args, **kwargs)
        record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        self.assertEqual(dispatch_one(self.db, InspectingProvider()), "SENT")


    def test_unknown_row_change_cannot_report_sent(self):
        class ChangingProvider(MockProvider):
            def send(inner, *args, **kwargs):
                receipt = super().send(*args, **kwargs)
                self.db.execute("UPDATE outbox_events SET payload_hash='changed' WHERE state='UNKNOWN'")
                return receipt
        record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit"})
        self.assertEqual(dispatch_one(self.db, ChangingProvider()), "UNKNOWN")
        self.assertIsNone(self.db.execute("SELECT provider_receipt FROM outbox_events").fetchone()[0])

    def test_invalid_shape_stops_before_writes(self):
        with self.assertRaises(ValueError):
            record_review(self.db, "r1", {"asset": "sample-video-7", "decision": "approve_edit", "extra": 1})
        self.assertEqual(self.db.execute("SELECT count(*) FROM review_requests").fetchone()[0], 0)

if __name__ == "__main__":
    unittest.main()
README-code.md · NOT RUN
# Transactional Outbox mock

This is an educational, local-only Python 3.12+ example using the standard library. It records a synthetic video-review decision and a notification intent in the same SQLite transaction, then demonstrates a separate dispatcher and an in-memory mock provider. It contains no network client, credentials, external API, scheduler, deployment code, or real review/video data. The sample deliberately has no UNKNOWN reconciliation or reset operation.

## Environment and permissions

- Python 3.12 or newer; no packages to install and no environment variables.
- Running `--demo` creates a temporary SQLite database under the operating system's temporary directory and removes it on exit. Running tests also creates a temporary database. Neither should be run against production data.
- The mock's receipt map exists only in process memory. It does not prove provider idempotency after a process restart.
- No API, hosting, or usage cost is incurred by this mock. A future sandbox or live provider would require separate authorization, credentials, privacy review, provider-specific idempotency/status semantics, and cost/latency measurement.

## Run

From this folder, an owner may choose to run:

```powershell
python code/outbox_mock.py --demo
```

Illustrative output (not observed in this task):

```text
locally_recorded=review:synthetic-review-001
dispatch=SENT
state=SENT
```

To run authored invariants from this run folder:

```powershell
python -m unittest discover -s code -p "test_outbox_mock.py" -v
```

Tests were authored but NOT RUN. Import, compile, database creation, and execution were intentionally not performed while preparing this package.

## Mock, sandbox, live

**Mock** means deterministic local behavior with synthetic data and no network. **Sandbox** means a provider-owned test environment that may still transmit data and create provider-side effects; it needs explicit credentials, permissions, and cleanup rules. **Live** means real provider actions, real data, and possible cost. This package implements only Mock. Do not read its output as evidence for Sandbox or Live delivery guarantees.
H✦HYBRID MINDINTERACTIVE EDITION LIVE EXPERIENCE
HYBRID MIND ORIGINAL / AI AGENT WORKFLOW / 2026

จากคลิปดิบ
สู่จักรวาลคอนเทนต์

ChatGPT ตัดต่อวิดีโอแทนคนได้จริงแค่ไหน? สำรวจระบบ AI Video Editing Agent ผ่านฉากสามมิติที่โต้ตอบได้ พร้อมแยกข้อเท็จจริง สร้างพรอมป์ต์ และจำลองต้นทุนก่อนลงมือทำจริง

06 EDITING STAGES03 CONTENT FORMATS01 INTERACTIVE LAB

ออกแบบระบบตัดต่อในมุมมอง 3D

ลากบนฉากเพื่อหมุนมุมมอง แตะโหนดเพื่อดูหน้าที่ของ AI ในแต่ละขั้นตอน

แผนภาพ 6 ขั้นตอน: Footage, Story, Edit, Captions, Graphics, Render
AGENT PIPELINE / VIEWPORT ORBIT 3D
DRAG / SWIPE TO ROTATE
SELECT NODE TO INSPECT
01 / 06นำเข้าคลิปดิบ

จัดระเบียบไฟล์ ตรวจคุณภาพภาพและเสียง และกำหนดสิทธิ์เข้าถึงให้ AI Agent

● SOURCE
จุดสำคัญ: AI Agent ทำหน้าที่วางแผนและควบคุมเครื่องมือ ส่วนการตัดต่อจริงต้องมีซอฟต์แวร์หรือระบบเรนเดอร์รองรับ ฉากนี้เป็น แบบจำลองเชิงอธิบาย ไม่ใช่ระบบอัปโหลดหรือตัดต่อวิดีโอ

แยกความสามารถจริงจากคำโฆษณา

กดแต่ละประเด็นเพื่ออ่านรายละเอียดและแหล่งข้อมูล

EVIDENCE-FIRST
✓ChatGPT Work เข้าถึงไฟล์ในเครื่องได้ตรวจสอบแล้ว / มีเงื่อนไข+
รองรับการทำงานกับโฟลเดอร์หรือแอปบนคอมพิวเตอร์เดสก์ท็อป เมื่อแผนและการตั้งค่าอนุญาตและผู้ใช้ให้สิทธิ์ ส่วน Work บนมือถือหรือเว็บไม่ได้เข้าถึงโฟลเดอร์ในคอมพิวเตอร์ของคุณโดยตรง ที่มา: OpenAI Help ↗
✓Astra ใช้โปรแกรมคอมพิวเตอร์ทำงานได้ตรวจสอบแล้ว / ไม่ใช่โปรแกรมตัดต่อในตัว+
OpenAI อธิบายความสามารถด้าน computer use ของ Astra รวมถึงงานสื่อที่ใช้เครื่องมือ แต่ต้องแยกบทบาท AI ผู้ควบคุมออกจากซอฟต์แวร์ประมวลผลวิดีโอและการเรนเดอร์ไฟล์ ที่มา: OpenAI ↗
?ตัดเสร็จใน 5–10 นาทีทุกกรณียังพิสูจน์เป็นเกณฑ์ทั่วไปไม่ได้+
เวลาขึ้นกับความยาวคลิป จำนวนทรานซิชัน คุณภาพซับ เครื่องมือ และประสิทธิภาพคอมพิวเตอร์ ควรใช้การจับเวลางานจริงแบบต้นทางเดียวกัน ไม่ใช้ตัวเลขจากโพสต์เป็นเกณฑ์อ้างอิง
?ถูกกว่าจ้างคน 10 เท่าต้องกำหนดต้นทุนให้เทียบกันได้+
ต้องรวมเวลาตรวจแก้ ลิขสิทธิ์เสียง เครื่องมือที่จ่ายเพิ่ม และต้นทุนโมเดล ยังไม่มีหลักฐานว่าประหยัด 10 เท่าสำหรับทุกงาน ทดลองสมมติฐานส่วนตัวได้ใน Cost Simulator ด้านล่าง

หนึ่งไอเดีย สามรูปแบบคอนเทนต์

ปรับรูปแบบวิดีโอ แล้วดูกรอบภาพตัวอย่างและ Prompt เปลี่ยนตามแบบเรียลไทม์

WORKING INTERACTIVE
15 SEC180 SEC
LIVE CREATIVE PREVIEW9:16 · 45 SEC
HYBRID MIND / AI
AI EDITOR / EXPLAINEDAI Agent
ช่วยตัดต่อ?
ใส่ซับตามประโยคได้อัตโนมัติ
▶◉↗
CONCEPT PREVIEW · NOT RENDERED VIDEO
VERTICAL · SHORT FORMVisual Mockup

พร้อมนำไปใช้กับ AI Agent

ตัวสร้าง Prompt นี้ไม่อัปโหลดคลิป ไม่เรียก API เสียเงิน และไม่ตัดต่อวิดีโอจริง ต้องใช้กับเครื่องมือที่ผู้ใช้อนุญาตภายหลัง

จำลองเวลาและต้นทุนของคุณเอง

เลื่อนตัวเลขเพื่อเปรียบเทียบสถานการณ์สมมติ ไม่ใช่ผล benchmark ที่ยืนยันแล้ว

SCENARIO · NOT BENCHMARK
ESTIMATED TIME SAVED11.0 ชม./เดือน
18.0 ชม.
7.0 ชม.
ส่วนต่างต้นทุนเวลา + เครื่องมือ (สมมติ)฿3,920

ตัวเลขเป็นตัวอย่างเพื่อช่วยตัดสินใจ ไม่ใช่ผลประหยัดที่รับประกัน

AI ที่ตัดคลิปเป็น ไม่ได้ทำงานลำพัง

สิ่งที่น่าสนใจของ GPT-6 Astra และ GPT-6.1 Sol ไม่ได้อยู่ที่ว่าตัวโมเดลมีปุ่ม Render MP4 ซ่อนอยู่ แต่คือความสามารถในการจัดการงานหลายขั้นตอนร่วมกับเครื่องมืออื่น: อ่านไฟล์ วางลำดับเรื่อง ใช้แอป ตรวจผลลัพธ์ และแก้งานตามข้อเสนอแนะของมนุษย์

สำหรับคนทำคอนเทนต์ ความต่างสำคัญคือการเปลี่ยนจาก “ส่งคลิปให้ AI แล้วหวังว่าจะได้ผลงาน” ไปเป็น Workflow ที่ตรวจรับได้ทุกขั้น ตั้งแต่การเลือกช่วงเด็ด การสร้างซับ การตรวจคำผิด จนถึงการเรนเดอร์ไฟล์ตามสัดส่วนของแต่ละแพลตฟอร์ม

ข้อควรระวังคือระบบ Work ที่รันบนเว็บหรือมือถือไม่ได้หมายความว่า AI เข้าถึงคลิปบนคอมพิวเตอร์บ้านได้ทันที ต้องทำงานในสภาพแวดล้อมที่เปิดให้เข้าถึงไฟล์จริง มีเครื่องมือตัดต่อ และมีสิทธิ์ใช้งานอย่างชัดเจน

01จัดคลิปให้พร้อม

รวบรวมไฟล์ในโฟลเดอร์ที่เข้าถึงได้ ระบุสิทธิ์และข้อจำกัด

02ตรวจสคริปต์และไทม์ไลน์

ให้ Agent เสนอ Hook, ช่วงตัด และโครงเรื่องก่อนตัดต่อ

03สร้างซับ + กราฟิก

ตรวจชื่อเฉพาะ ตัวเลข ภาษาไทย และความถูกต้องของคำกล่าวอ้าง

04ตรวจไฟล์ MP4 จริง

ตรวจความละเอียด เสียง ระยะเวลา safe zone และคำบรรยาย

ใส่ความเห็น

Discover more from HYBRID MIND

Subscribe now to keep reading and get access to the full archive.

Continue reading