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

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

โครงสร้างข้อมูลและ 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 ไม่ใช่หลักฐานจากบริการจริง

ลำดับการทำงาน
- เครื่องมือรับคำขอที่ผ่าน schema และสิทธิ์แล้ว โดยผูกกับ
review_idที่ระบบยืนยัน - เปิด
BEGIN IMMEDIATEตรวจ ID เดิม หาก payload ตรงกันคืนผลเดิม หากขัดกันให้หยุด - แทรกแถวรีวิวและแถว outbox แล้ว commit ทั้งคู่ ถ้าผิดพลาดก่อน commit ให้ rollback
- dispatcher อ่านรายการที่ commit แล้วนอก write transaction จากนั้นเปลี่ยนจาก
PENDINGเป็นUNKNOWNและ commit ก่อนเรียก provider - เมื่อได้คำตอบรับที่ชัดเจน จึงเขียน 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 แต่ก่อน call | UNKNOWN แม้ยังไม่ส่ง | ต้องตรวจหลักฐานก่อนออกแบบทางแก้ |
| เขียน 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
มุมเริ่มต้น: เห็นธุรกรรมภายในและผู้ให้บริการภายนอกแยกกัน
คำอธิบายภาพแบบข้อความ
ภายใน SQLite มีคำขอกับ outbox หนึ่งคู่ การ commit สำเร็จทำให้คู่นี้มีอยู่พร้อมกัน Dispatcher อ่านภายหลัง แล้วบันทึก UNKNOWN ก่อนเรียกปลายทาง จึงไม่มีช่วงที่เริ่มส่งแล้วฐานข้อมูลยังบอกว่าส่งได้อย่างปลอดภัย ผลตอบรับแน่ชัดเท่านั้นจึงเปลี่ยนเป็น SENT การหมุนภาพเป็นเพียงมุมมอง
เลือกจุดขัดข้อง แล้วแยกสิ่งที่รู้กับสิ่งที่ต้องตรวจ
แบบจำลองแยกนี้ใช้เหตุการณ์ที่กำหนดไว้ล่วงหน้า ไม่มีการทดสอบระบบจริง เลือกหนึ่งกรณีแล้วอ่านสถานะกับสิ่งที่ควรทำต่อ โดยไม่ถือสีเขียวเป็นหลักฐานว่าปลายทางจริงทำงานสำเร็จ
ตัวอย่างนี้ไม่มีปุ่มส่งซ้ำจาก 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 กลายเป็นทราบแน่
แหล่งอ้างอิง
- AWS Prescriptive Guidance: Transactional outbox — dual write และการส่ง event หลัง commit
- SQLite: Transaction — คำสั่ง transaction และ BEGIN IMMEDIATE
- Python 3.12 sqlite3 — autocommit และ transaction control
- AWS Builders’ Library: Making retries safe with idempotent APIs — request identity และการปฏิเสธพารามิเตอร์ที่ขัดกัน
- Microservices.io: Transactional outbox — relay และความเสี่ยง duplicate delivery
ขอบเขตหลักฐาน: แหล่งอ้างอิงอธิบายแนวคิดทั่วไป ส่วน 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.
จากคลิปดิบ
สู่จักรวาลคอนเทนต์
ChatGPT ตัดต่อวิดีโอแทนคนได้จริงแค่ไหน? สำรวจระบบ AI Video Editing Agent ผ่านฉากสามมิติที่โต้ตอบได้ พร้อมแยกข้อเท็จจริง สร้างพรอมป์ต์ และจำลองต้นทุนก่อนลงมือทำจริง
ออกแบบระบบตัดต่อในมุมมอง 3D
ลากบนฉากเพื่อหมุนมุมมอง แตะโหนดเพื่อดูหน้าที่ของ AI ในแต่ละขั้นตอน
จัดระเบียบไฟล์ ตรวจคุณภาพภาพและเสียง และกำหนดสิทธิ์เข้าถึงให้ AI Agent
แยกความสามารถจริงจากคำโฆษณา
กดแต่ละประเด็นเพื่ออ่านรายละเอียดและแหล่งข้อมูล
ChatGPT Work เข้าถึงไฟล์ในเครื่องได้ตรวจสอบแล้ว / มีเงื่อนไข+
Astra ใช้โปรแกรมคอมพิวเตอร์ทำงานได้ตรวจสอบแล้ว / ไม่ใช่โปรแกรมตัดต่อในตัว+
ตัดเสร็จใน 5–10 นาทีทุกกรณียังพิสูจน์เป็นเกณฑ์ทั่วไปไม่ได้+
ถูกกว่าจ้างคน 10 เท่าต้องกำหนดต้นทุนให้เทียบกันได้+
หนึ่งไอเดีย สามรูปแบบคอนเทนต์
ปรับรูปแบบวิดีโอ แล้วดูกรอบภาพตัวอย่างและ Prompt เปลี่ยนตามแบบเรียลไทม์
ช่วยตัดต่อ?
พร้อมนำไปใช้กับ AI Agent
ตัวสร้าง Prompt นี้ไม่อัปโหลดคลิป ไม่เรียก API เสียเงิน และไม่ตัดต่อวิดีโอจริง ต้องใช้กับเครื่องมือที่ผู้ใช้อนุญาตภายหลัง
จำลองเวลาและต้นทุนของคุณเอง
เลื่อนตัวเลขเพื่อเปรียบเทียบสถานการณ์สมมติ ไม่ใช่ผล benchmark ที่ยืนยันแล้ว
ตัวเลขเป็นตัวอย่างเพื่อช่วยตัดสินใจ ไม่ใช่ผลประหยัดที่รับประกัน
AI ที่ตัดคลิปเป็น ไม่ได้ทำงานลำพัง
สิ่งที่น่าสนใจของ GPT-6 Astra และ GPT-6.1 Sol ไม่ได้อยู่ที่ว่าตัวโมเดลมีปุ่ม Render MP4 ซ่อนอยู่ แต่คือความสามารถในการจัดการงานหลายขั้นตอนร่วมกับเครื่องมืออื่น: อ่านไฟล์ วางลำดับเรื่อง ใช้แอป ตรวจผลลัพธ์ และแก้งานตามข้อเสนอแนะของมนุษย์
สำหรับคนทำคอนเทนต์ ความต่างสำคัญคือการเปลี่ยนจาก “ส่งคลิปให้ AI แล้วหวังว่าจะได้ผลงาน” ไปเป็น Workflow ที่ตรวจรับได้ทุกขั้น ตั้งแต่การเลือกช่วงเด็ด การสร้างซับ การตรวจคำผิด จนถึงการเรนเดอร์ไฟล์ตามสัดส่วนของแต่ละแพลตฟอร์ม
ข้อควรระวังคือระบบ Work ที่รันบนเว็บหรือมือถือไม่ได้หมายความว่า AI เข้าถึงคลิปบนคอมพิวเตอร์บ้านได้ทันที ต้องทำงานในสภาพแวดล้อมที่เปิดให้เข้าถึงไฟล์จริง มีเครื่องมือตัดต่อ และมีสิทธิ์ใช้งานอย่างชัดเจน
รวบรวมไฟล์ในโฟลเดอร์ที่เข้าถึงได้ ระบุสิทธิ์และข้อจำกัด
ให้ Agent เสนอ Hook, ช่วงตัด และโครงเรื่องก่อนตัดต่อ
ตรวจชื่อเฉพาะ ตัวเลข ภาษาไทย และความถูกต้องของคำกล่าวอ้าง
ตรวจความละเอียด เสียง ระยะเวลา safe zone และคำบรรยาย

ใส่ความเห็น