บทที่ 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. ReserveFundsReleaseReservation
2. DeductFeeRefundFee
3. BankTransfer(ไม่มี — ถ้าล้มก็คือไม่เกิด)
4. CommitDeductionReverseDeduction

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 จะทำงานต่อจนจบเสมอ

cancelterminate
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 จนกว่าจะสำเร็จ
  • ไม่รู้ว่าอยู่ฝั่งไหน → หยุด แล้วเรียกคน