บทที่ 4 · Part 1 — Foundation
Determinism & Replay
หัวใจของ Temporal — ทำไม workflow ต้อง deterministic, สิ่งที่ห้ามทำใน Go และเครื่องมือ workflowcheck
บทที่สำคัญที่สุดของคอร์ส ทุกกฎแปลกๆ ของ Temporal ที่คุณจะเจอต่อจากนี้ ล้วนสืบย้อนกลับมาที่กลไกเดียวคือ history replay
จบบทนี้คุณจะ
- อธิบายได้ว่า replay ทำงานยังไง และทำไม workflow ต้อง deterministic
- จำรายการสิ่งที่ห้ามทำใน workflow code ของ Go ได้
- ใช้
workflowcheckดักปัญหาตั้งแต่ CI
กลไก Replay
Worker ไม่ได้เก็บสถานะ workflow ไว้ในหน่วยความจำถาวร เมื่อต้องกู้สถานะ (worker ตาย, cache ถูก evict, หรือกลับมาหลัง timer ยาว) มันจะ รันโค้ด workflow ใหม่ตั้งแต่บรรทัดแรก
ประเด็นสำคัญคือ activity ไม่ถูกเรียกซ้ำระหว่าง replay
เมื่อโค้ดวิ่งมาถึงบรรทัด ExecuteActivity(ctx, a.ChargeBank, req) อีกครั้ง
SDK เห็นว่า history มี ActivityTaskCompleted ของ activity นี้อยู่แล้ว
ก็จะคืนค่าเดิมกลับมาทันทีโดยไม่ยิงไปธนาคารซ้ำ
Command กับ Event
| โค้ดใน workflow | Command ที่สร้าง | Event ที่บันทึก |
|---|---|---|
ExecuteActivity(...) | ScheduleActivityTask | ActivityTaskScheduled |
workflow.Sleep(...) | StartTimer | TimerStarted |
ExecuteChildWorkflow(...) | StartChildWorkflowExecution | ChildWorkflowExecutionStarted |
return result, nil | CompleteWorkflowExecution | WorkflowExecutionCompleted |
SDK ตรวจสอบว่า ลำดับและชนิดของ Command ต้องตรงกับ Event เป๊ะๆ ถ้าโค้ดใหม่สร้าง Command ที่ไม่ตรงกับที่บันทึกไว้ = non-determinism
ตัวอย่างที่พังจริง
// โค้ดที่มีบั๊ก — ตัดสินใจจากเวลาปัจจุบัน
func TransferWorkflow(ctx workflow.Context, req TransferRequest) error {
// ผิด! time.Now() ให้ค่าต่างกันทุกครั้งที่รัน
if time.Now().Hour() < 22 {
return workflow.ExecuteActivity(ctx, a.InstantTransfer, req).Get(ctx, nil)
}
return workflow.ExecuteActivity(ctx, a.NextDayTransfer, req).Get(ctx, nil)
}
รันครั้งแรก 21:59 น.
time.Now().Hour() = 21 → เรียก InstantTransfer
history บันทึก: ActivityTaskScheduled(InstantTransfer)
Worker ตาย · Replay ตอน 22:01 น.
time.Now().Hour() = 22 → เรียก NextDayTransfer
Command = ScheduleActivityTask(NextDayTransfer)
Event = ActivityTaskScheduled(InstantTransfer)
▲ ไม่ตรงกัน
→ NondeterminismError → ธุรกรรมค้างจนกว่าคนจะเข้าไปแก้
// แก้ด้วย workflow.Now
// workflow.Now(ctx) คืนเวลาที่ถูกบันทึกไว้ใน history
// จึงได้ค่าเดิมเสมอไม่ว่าจะ replay กี่ครั้ง
if workflow.Now(ctx).Hour() < 22 {
// ...
}
รายการต้องห้ามใน Go
Go SDK ไม่มี runtime sandbox (Python และ TypeScript มี) แปลว่าไม่มีอะไรมาหยุดคุณตอนเขียนโค้ด บั๊กจะโผล่ตอน replay บน production เท่านั้น รายการนี้จึงต้องจำให้ขึ้นใจ
| ห้ามใช้ | ใช้แทน | ทำไม |
|---|---|---|
go func() { ... }() | workflow.Go(ctx, ...) | goroutine จริงจัดลำดับไม่แน่นอน |
make(chan T) | workflow.NewChannel(ctx) | channel จริงไม่ถูกควบคุมโดย scheduler ของ SDK |
select { ... } | workflow.NewSelector(ctx) | select เลือก case แบบสุ่มเมื่อพร้อมหลายตัว |
time.Now() | workflow.Now(ctx) | นาฬิกาเดินไปเรื่อยๆ |
time.Sleep(d) | workflow.Sleep(ctx, d) | ต้องเป็น durable timer ที่บันทึกใน history |
time.After(d) | workflow.NewTimer(ctx, d) | เหตุผลเดียวกัน |
rand.Intn(n) | workflow.SideEffect(...) | ค่าสุ่มต่างกันทุกครั้ง |
uuid.New() | workflow.SideEffect(...) หรือรับมาเป็น input | เหตุผลเดียวกัน |
crypto/rand.Reader | ทำใน activity | เหตุผลเดียวกัน |
for k := range myMap | sort key ก่อนวนลูป | Go สุ่มลำดับการวน map โดยเจตนา |
fmt.Println / log.Println | workflow.GetLogger(ctx) | log จะซ้ำทุกครั้งที่ replay |
| HTTP / DB / อ่านไฟล์ / env var | ย้ายไปไว้ใน activity | ผลลัพธ์เปลี่ยนได้ระหว่างการรันแต่ละครั้ง |
| แก้ค่า global variable | ใช้ตัวแปร local | state รั่วข้าม workflow execution |
| anonymous function เป็น local activity | ใช้ named function | ชื่อที่ SDK สร้างให้ไม่คงที่ข้าม build |
กรณี map ที่เจอบ่อยในระบบการเงิน
// ผิด — ลำดับการโอนไม่แน่นอน
// จ่ายเงินให้ merchant หลายราย
for merchantID, amount := range payouts {
workflow.ExecuteActivity(ctx, a.Payout, merchantID, amount).Get(ctx, nil)
}
// ถูก — เรียงก่อนเสมอ
ids := make([]string, 0, len(payouts))
for id := range payouts {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
workflow.ExecuteActivity(ctx, a.Payout, id, payouts[id]).Get(ctx, nil)
}
ทำไมบั๊กแบบนี้อันตรายเป็นพิเศษ
โค้ดจะทำงานถูกต้อง 100% ในการรันปกติ และผ่าน test ทุกตัว มันพังเฉพาะตอน replay เท่านั้น — คือตอนที่ worker ตายกลางคัน ซึ่งเป็นเวลาที่คุณต้องการให้ระบบทำงานถูกต้องที่สุด
SideEffect และ MutableSideEffect
ถ้าจำเป็นต้องได้ค่าที่ไม่ deterministic แบบเบาๆ ในตัว workflow เอง
(ไม่คุ้มที่จะทำเป็น activity) ให้ห่อด้วย workflow.SideEffect
ซึ่งจะรันครั้งเดียวแล้วบันทึกผลลง history ครั้งต่อไปที่ replay จะดึงค่าเดิมมาใช้
encoded := workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} {
return uuid.NewString()
})
var idempotencyKey string
encoded.Get(&idempotencyKey)
แต่ในเคสการเงิน มักมีทางที่ดีกว่า
แทนที่จะสุ่ม idempotency key ให้ใช้ค่าที่ deterministic อยู่แล้ว เช่น
workflow.GetInfo(ctx).WorkflowExecution.ID หรือ transaction ID ที่รับมาเป็น input
จะตรวจสอบย้อนหลังและ reconcile กับธนาคารได้ง่ายกว่ามาก
MutableSideEffect ใช้เมื่อค่าอาจเปลี่ยนได้ระหว่าง workflow ทำงาน
และคุณต้องการบันทึกลง history เฉพาะตอนที่ค่าเปลี่ยนจริงเท่านั้น
(เช่น อ่านค่า config ที่ปรับได้ระหว่างทาง) ใช้ไม่บ่อยและควรคิดให้ดีก่อนใช้
การเปลี่ยนแปลงที่ปลอดภัย vs ไม่ปลอดภัย
ไม่ใช่ทุกการแก้โค้ดจะทำให้ non-determinism คำถามคือ ลำดับ Command เปลี่ยนหรือไม่
| ปลอดภัย | ไม่ปลอดภัย (ต้องทำ versioning) |
|---|---|
| แก้โค้ดข้างใน activity (activity ไม่ถูก replay) | เพิ่ม / ลบ / สลับลำดับการเรียก activity |
| เปลี่ยน retry policy หรือ timeout | เปลี่ยนชื่อ activity หรือ workflow |
| เปลี่ยน argument ที่ส่งให้ activity | เพิ่ม / ลบ workflow.Sleep หรือ timer |
| เพิ่ม signal / query / update handler ใหม่ | เปลี่ยนเงื่อนไข if ที่คุมว่าจะเรียก activity ตัวไหน |
| แก้ log message | เพิ่ม / ลบ child workflow |
| เปลี่ยน duration ของ timer | เปลี่ยนโครงสร้าง workflow.Go ที่มีอยู่ |
การเปลี่ยนแปลงในคอลัมน์ขวาต้องใช้กลไก versioning ซึ่งอยู่ในบทที่ 10
workflowcheck — ดักตั้งแต่ CI
go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest
workflowcheck ./...
# แสดงตำแหน่งไฟล์และบรรทัด
workflowcheck -show-pos ./...
ไม่มี output = ไม่พบปัญหา สิ่งที่มันตรวจจับได้:
time.Now,time.Sleepcrypto/rand.Reader, globalmath/randos.Stdin/os.Stdout/os.Stderr- การสร้าง goroutine, ส่ง/รับ channel,
rangeบน channel rangeบน map
มันไม่ได้ครอบคลุมทุกอย่าง
workflowcheck จับไม่ได้: การแก้ global variable,
non-determinism ผ่าน reflection, และเงื่อนไขที่ไม่แน่นอนเฉพาะตอน runtime
เพราะฉะนั้น ต้องมี replay test ด้วยเสมอ (บทที่ 9)
ถ้าเจอ false positive ให้ปิดเป็นรายบรรทัด:
now := time.Now() //workflowcheck:ignore
เมื่อเกิด NondeterminismError ขึ้นมาแล้ว
error นี้มีรหัส TMPRL1100 และจะปรากฏเป็น WorkflowTaskFailed ใน history
workflow จะไม่ล้ม แต่จะค้าง — Temporal จะ retry workflow task ไปเรื่อยๆ
ซึ่งเป็นเรื่องดี เพราะแปลว่าถ้าคุณแก้โค้ดถูก มันจะเดินต่อเองโดยไม่เสียธุรกรรม
ถ้า revert ไม่ได้แล้วจริงๆ ยังมี temporal workflow reset
ที่ย้อน workflow กลับไปยัง event ก่อนหน้าแล้วเดินใหม่ แต่ต้องระวังมากในระบบการเงิน:
การ reset จะรัน activity ที่อยู่หลังจุด reset ใหม่ทั้งหมด ซึ่งจะปลอดภัยก็ต่อเมื่อ
activity เหล่านั้น idempotent จริง
สรุปกฎเดียวที่ต้องจำ
Workflow มีหน้าที่ตัดสินใจ ไม่ใช่ลงมือทำ — ถ้าโค้ดบรรทัดนั้นต้องคุยกับโลกภายนอก หรือให้ผลต่างกันในแต่ละครั้งที่รัน มันต้องอยู่ใน activity ไม่ใช่ใน workflow