บทที่ 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

โค้ดใน workflowCommand ที่สร้างEvent ที่บันทึก
ExecuteActivity(...)ScheduleActivityTaskActivityTaskScheduled
workflow.Sleep(...)StartTimerTimerStarted
ExecuteChildWorkflow(...)StartChildWorkflowExecutionChildWorkflowExecutionStarted
return result, nilCompleteWorkflowExecutionWorkflowExecutionCompleted

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 myMapsort key ก่อนวนลูปGo สุ่มลำดับการวน map โดยเจตนา
fmt.Println / log.Printlnworkflow.GetLogger(ctx)log จะซ้ำทุกครั้งที่ replay
HTTP / DB / อ่านไฟล์ / env varย้ายไปไว้ใน activityผลลัพธ์เปลี่ยนได้ระหว่างการรันแต่ละครั้ง
แก้ค่า global variableใช้ตัวแปร localstate รั่วข้าม 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.Sleep
  • crypto/rand.Reader, global math/rand
  • os.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