บทที่ 6 · Part 2 — Building
Error Handling & Saga
แยก retryable/non-retryable, compensation pattern สำหรับโอนเงินออกจาก wallet และ cancellation
เมื่อไม่มี distributed transaction ให้ใช้ คำถามไม่ใช่ "จะป้องกันไม่ให้ล้มกลางคันยังไง" แต่คือ "ล้มกลางคันแล้วจะกลับสู่สถานะที่ถูกต้องยังไง"
จบบทนี้คุณจะ
- อ่านและแยกชนิด error ที่ workflow ได้รับ
- เขียน Saga พร้อม compensation สำหรับการโอนเงินออกจาก wallet
- จัดการ cancellation โดยที่ compensation ยังทำงาน
- รู้ว่าเมื่อไหร่ควรหยุดแล้วให้คนเข้ามาจัดการ
ชนิดของ error ใน workflow
เมื่อ activity ล้ม workflow จะได้ *temporal.ActivityError ที่ห่อ error ตัวจริงไว้
ต้องใช้ errors.As แกะออกมา
err := workflow.ExecuteActivity(ctx, a.ChargeBank, req).Get(ctx, &bankRef)
if err != nil {
var appErr *temporal.ApplicationError
if errors.As(err, &appErr) {
switch appErr.Type() {
case "BankRejected":
return handleRejection(ctx, req, appErr)
case "LimitExceeded":
return TopUpResult{}, err
}
}
var timeoutErr *temporal.TimeoutError
if errors.As(err, &timeoutErr) {
switch timeoutErr.TimeoutType() {
case enumspb.TIMEOUT_TYPE_START_TO_CLOSE:
// อาจทำสำเร็จไปแล้วก็ได้ ต้องไปตรวจสอบสถานะจริง
return reconcileWithBank(ctx, req)
case enumspb.TIMEOUT_TYPE_HEARTBEAT:
// activity แขวน
}
}
var canceledErr *temporal.CanceledError
if errors.As(err, &canceledErr) {
// ถูกยกเลิก
}
var panicErr *temporal.PanicError
if errors.As(err, &panicErr) {
// มีบั๊กใน activity — panicErr.StackTrace() มีรายละเอียด
}
return TopUpResult{}, err
}
TimeoutError กับเงิน = ต้องระวังเป็นพิเศษ
timeout ไม่ได้แปลว่า "ไม่เกิดขึ้น" แต่แปลว่า "ไม่รู้" ธนาคารอาจทำรายการสำเร็จแล้วแต่ตอบกลับไม่ทัน ในเคสนี้ห้ามสรุปว่าล้มเหลว ต้องไปสอบถามสถานะจริงจากธนาคารก่อน (query by reference)
พฤติกรรมเฉพาะของ Go SDK
ต่างจาก Python/TypeScript
ใน Go การ return error ใดๆ ออกจากฟังก์ชัน workflow จะทำให้ workflow
ล้มทันทีโดยไม่มี retry อัตโนมัติ
(ใน Python/TS error ที่ไม่ใช่ ApplicationError จะทำให้ workflow task retry ไปเรื่อยๆ)
ถ้าต้องการให้ workflow ทั้งตัว retry ต้องตั้ง RetryPolicy ใน
StartWorkflowOptions เอง ซึ่งสำหรับ flow การเงินมักไม่ใช่สิ่งที่ต้องการ
ทำไมต้อง Saga
พิจารณาการโอนเงินออกจาก wallet เข้าบัญชีธนาคาร (withdraw / cash-out):
ถ้าขั้นที่ 3 ล้มถาวร (ธนาคารปฏิเสธเพราะเลขบัญชีผิด) เราต้องย้อนขั้นที่ 2 และ 1
ไม่มี ROLLBACK ให้ใช้ข้ามระบบ — ต้องทำ compensating action ทีละขั้นแบบย้อนกลับ
| ขั้นตอน | compensation |
|---|---|
| 1. ReserveFunds | ReleaseReservation |
| 2. DeductFee | RefundFee |
| 3. BankTransfer | (ไม่มี — ถ้าล้มก็คือไม่เกิด) |
| 4. CommitDeduction | ReverseDeduction |
Saga ใน Go
func WithdrawWorkflow(ctx workflow.Context, req WithdrawRequest) (WithdrawResult, error) {
logger := workflow.GetLogger(ctx)
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: time.Minute,
RetryPolicy: &temporal.RetryPolicy{
NonRetryableErrorTypes: []string{"BankRejected", "InvalidAccount"},
},
})
var a *activities.Activities
var compensations []func(ctx workflow.Context) error
// รัน compensation ทั้งหมดแบบย้อนลำดับ
// ใช้ disconnected context เพื่อให้ทำงานได้แม้ workflow ถูก cancel
runCompensations := func() {
disconnected, _ := workflow.NewDisconnectedContext(ctx)
compCtx := workflow.WithActivityOptions(disconnected, workflow.ActivityOptions{
StartToCloseTimeout: time.Minute,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 10, // พยายามหนักหน่อย เพราะนี่คือการคืนเงินลูกค้า
},
})
for i := len(compensations) - 1; i >= 0; i-- {
if err := compensations[i](compCtx); err != nil {
// compensation ล้ม = ต้องมีคนเข้ามาดู
logger.Error("COMPENSATION FAILED - manual intervention required",
"txnID", req.TransactionID, "step", i, "error", err)
_ = workflow.ExecuteActivity(compCtx, a.RaiseOpsAlert,
req.TransactionID, "compensation failed").Get(compCtx, nil)
}
}
}
// ── ขั้นที่ 1: reserve ยอดใน wallet ──────────────────────
// ลงทะเบียน compensation ก่อนเรียก activity เสมอ
// เพราะ activity อาจทำสำเร็จแล้วแต่ล้มตอนรายงานผลกลับ
compensations = append(compensations, func(c workflow.Context) error {
return workflow.ExecuteActivity(c, a.ReleaseReservation, req.TransactionID).Get(c, nil)
})
if err := workflow.ExecuteActivity(actCtx, a.ReserveFunds, req).Get(actCtx, nil); err != nil {
runCompensations()
return WithdrawResult{}, err
}
// ── ขั้นที่ 2: หักค่าธรรมเนียม ──────────────────────────
compensations = append(compensations, func(c workflow.Context) error {
return workflow.ExecuteActivity(c, a.RefundFee, req.TransactionID).Get(c, nil)
})
if err := workflow.ExecuteActivity(actCtx, a.DeductFee, req).Get(actCtx, nil); err != nil {
runCompensations()
return WithdrawResult{}, err
}
// ── ขั้นที่ 3: โอนออกจริง ───────────────────────────────
var bankRef string
err := workflow.ExecuteActivity(actCtx, a.BankTransfer, req).Get(actCtx, &bankRef)
if err != nil {
runCompensations()
return WithdrawResult{}, err
}
// ── ขั้นที่ 4: ตัดยอดจริง ───────────────────────────────
// ผ่านจุดนี้แล้วเงินออกจากธนาคารไปแล้ว จะย้อนกลับไม่ได้อีก
// ขั้นนี้ต้อง retry จนกว่าจะสำเร็จเท่านั้น
commitCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: time.Minute,
RetryPolicy: &temporal.RetryPolicy{}, // retry ไม่จำกัด
})
if err := workflow.ExecuteActivity(commitCtx, a.CommitDeduction, req, bankRef).
Get(commitCtx, nil); err != nil {
return WithdrawResult{}, err
}
return WithdrawResult{TransactionID: req.TransactionID, BankRef: bankRef}, nil
}
3 จุดที่คนทำผิดบ่อย
1. ลงทะเบียน compensation หลังเรียก activity
ถ้าเขียน ExecuteActivity(ReserveFunds) ก่อนแล้วค่อย append compensation
จะมีช่องว่างที่ activity ทำสำเร็จ (เงินถูก hold แล้ว) แต่ระบบล้มก่อนบันทึก compensation
ผลคือเงินค้าง hold ตลอดไป ลงทะเบียนก่อนเสมอ
[!CAUTION] 2. Compensation ไม่ idempotent
compensation ก็คือ activity ธรรมดา จึงถูก retry ได้เหมือนกัน
RefundFee ที่คืนเงินซ้ำ 3 รอบคือบั๊กที่ร้ายแรงกว่าปัญหาเดิมที่พยายามแก้
ใช้ transaction ID เป็นกุญแจกันซ้ำเสมอ
[!CAUTION] 3. ไม่ได้จัดการกรณี compensation เองก็ล้ม ถ้าคืนเงินไม่สำเร็จ ระบบอยู่ในสถานะที่ไม่ถูกต้องและแก้ด้วยตัวเองไม่ได้แล้ว ต้อง log ให้ครบ ยิง alert ไปหาทีม ops และมี runbook รองรับ อย่าปล่อยให้ error หายไปเงียบๆ
จุดที่ย้อนกลับไม่ได้ (point of no return)
สังเกตขั้นที่ 4 ในโค้ด — หลังจากธนาคารโอนเงินออกไปแล้ว การ "ย้อนกลับ" ไม่ใช่ทางเลือกอีกต่อไป สิ่งเดียวที่ทำได้คือ เดินหน้าจนสำเร็จ (forward recovery) ดังนั้นทุกขั้นตอนหลังจุดนี้ต้องตั้ง retry แบบไม่จำกัด และห้ามมี non-retryable error
Cancellation
เมื่อมีคนสั่ง temporal workflow cancel — เช่น ทีม compliance สั่งหยุดรายการที่น่าสงสัย —
context ของ workflow จะถูกยกเลิก และ activity ที่ค้างอยู่จะได้รับสัญญาณ
ปัญหาคือ activity ที่รันด้วย context ที่ถูกยกเลิกแล้วจะรันไม่ได้
ซึ่งแปลว่า compensation ก็จะรันไม่ได้ด้วย ทางแก้คือ
workflow.NewDisconnectedContext
func WithdrawWorkflow(ctx workflow.Context, req WithdrawRequest) error {
defer func() {
if !errors.Is(ctx.Err(), workflow.ErrCanceled) {
return
}
// ถูกยกเลิก — เก็บกวาดด้วย context ที่ไม่ผูกกับการยกเลิก
cleanupCtx, _ := workflow.NewDisconnectedContext(ctx)
cleanupCtx = workflow.WithActivityOptions(cleanupCtx, workflow.ActivityOptions{
StartToCloseTimeout: time.Minute,
})
_ = workflow.ExecuteActivity(cleanupCtx, a.ReleaseReservation, req.TransactionID).
Get(cleanupCtx, nil)
}()
// ... ตรรกะปกติ ...
return nil
}
ฝั่ง activity ต้อง opt-in ที่จะรับรู้การยกเลิก โดยต้อง heartbeat และตรวจ ctx.Done()
ตามที่อธิบายไว้ในบทที่ 5 — activity ที่ไม่ heartbeat จะทำงานต่อจนจบเสมอ
cancel | terminate | |
|---|---|---|
| workflow รับรู้ไหม | รับรู้ — จัดการเองได้ | ไม่รับรู้ — ตายทันที |
| compensation ทำงานไหม | ทำ (ถ้าเขียนไว้) | ไม่ทำ |
| ใช้บน production | ใช้ตัวนี้ | ทางเลือกสุดท้ายเท่านั้น |
เมื่อไหร่ควรให้คนเข้ามาจัดการ
ระบบอัตโนมัติมีขีดจำกัด บางสถานการณ์การพยายามแก้เองต่อคือการทำให้แย่ลง ออกแบบให้ workflow "หยุดรอคนอย่างมีสติ" ดีกว่าปล่อยให้ล้มแล้วทิ้งสถานะค้างไว้
// ธนาคาร timeout เกิน 3 ครั้ง — เราไม่รู้สถานะจริง
// ห้ามเดา ให้หยุดรอคนตรวจสอบ
if unknownStateDetected {
_ = workflow.ExecuteActivity(actCtx, a.RaiseOpsAlert, req.TransactionID,
"bank state unknown, manual reconciliation required").Get(actCtx, nil)
// เปิด signal channel ให้ ops ตัดสินใจ
var decision OpsDecision
workflow.GetSignalChannel(ctx, "ops-decision").Receive(ctx, &decision)
switch decision.Action {
case "confirm-success":
return finishAsSuccess(ctx, req, decision.BankRef)
case "confirm-failed":
runCompensations()
return WithdrawResult{}, temporal.NewNonRetryableApplicationError(
"confirmed failed by ops", "OpsRejected", nil)
}
}
ข้อดีคือ workflow จะรอสถานะ Running ค้างไว้ได้เป็นวันโดยไม่กินทรัพยากร
ทีม ops เห็นรายการค้างทั้งหมดผ่าน Web UI และเมื่อตัดสินใจแล้วก็ส่ง signal เข้าไป
flow ก็เดินต่อจากจุดเดิม — เรื่อง signal อยู่ในบทถัดไป
หลักคิดสรุป
- ก่อน point of no return → ล้มแล้ว compensate
- หลัง point of no return → retry จนกว่าจะสำเร็จ
- ไม่รู้ว่าอยู่ฝั่งไหน → หยุด แล้วเรียกคน