บทที่ 10 · Part 3 — Production & Advanced

Versioning & Deploy

แก้โค้ดยังไงไม่ให้ธุรกรรมที่ค้างอยู่พัง — Patching API, Workflow Type และ Worker Deployment Versioning

Workflow ของคุณรันค้างอยู่ 3,000 รายการ แต่ละรายการรอ OTP อยู่ แล้วคุณต้อง deploy โค้ดใหม่ที่เพิ่มขั้นตอนตรวจ AML ก่อนโอนเงิน พอ worker ตัวใหม่ขึ้น มันจะ replay ธุรกรรมเก่าผ่านโค้ดใหม่ — แล้วพังทั้ง 3,000 รายการ

บทนี้คือวิธีแก้โค้ดโดยไม่ทำให้ของที่ค้างอยู่พัง

จบบทนี้คุณจะ

  • บอกได้ว่าการแก้แบบไหน breaking แบบไหนไม่
  • ใช้ workflow.GetVersion แก้ workflow ที่กำลังรันอยู่ได้อย่างปลอดภัย
  • เลือกได้ระหว่าง Patching, Workflow Type ใหม่ และ Worker Deployment Versioning
  • รู้ว่าเมื่อไหร่ควรลบ branch เก่าทิ้ง และตรวจยังไงว่าปลอดภัยแล้ว

ทำไมการ deploy ถึงอันตราย

ทบทวนจากบทที่ 4: workflow ไม่ได้เก็บ state ไว้ตรงๆ แต่สร้างใหม่ทุกครั้งด้วยการ replay event history โค้ดจึงต้องออก command ชุดเดิมเป๊ะทุกครั้ง

Workflow ที่เจอ non-determinism จะไม่ตายทันที แต่จะ ค้างอยู่ในสถานะ Running โดยที่ workflow task fail ซ้ำไปเรื่อยๆ ซึ่งกู้ได้ (deploy โค้ดเดิมกลับไปก่อน) แต่ระหว่างนั้นธุรกรรมของลูกค้าไม่เดิน

อะไร breaking อะไรไม่

การแก้breaking ไหม
เพิ่ม/ลบ/สลับลำดับ การเรียก activitybreaking
เพิ่ม/ลบ timer, child workflow, signal channelbreaking
เปลี่ยนเงื่อนไข if ที่ทำให้เดินคนละ branchbreaking
เปลี่ยนชื่อ activity หรือชื่อ workflow typebreaking
เปลี่ยน ActivityOptions (timeout, retry policy)ไม่ breaking
แก้โค้ดภายใน activityไม่ breaking (ไม่อยู่ใน history)
เพิ่ม log, comment, refactor ที่ไม่เปลี่ยนลำดับ commandไม่ breaking
เพิ่ม field ใหม่ที่มี default ใน struct ของ input/outputไม่ breaking (ถ้า converter รองรับ)

กฎง่ายๆ ที่ใช้ตัดสินได้เกือบทุกครั้ง

ถามว่า "การแก้นี้ทำให้ ลำดับหรือชนิดของ command ที่ workflow ออกเปลี่ยนไปไหม" ถ้าใช่ = breaking ถ้าไม่ = ปลอดภัย ตรรกะที่อยู่ใน activity ไม่เคยเป็น breaking เพราะ history เก็บแค่ผลลัพธ์ ไม่ได้เก็บวิธีคิด

นี่คือเหตุผลหนึ่งที่ควรผลักตรรกะที่เปลี่ยนบ่อย (เช่นกฎ fee, กฎ limit) ลงไปใน activity แทนที่จะเขียนไว้ใน workflow

วิธีที่ 1: Patching ด้วย GetVersion

workflow.GetVersion บันทึก marker ลง history ตอนรันครั้งแรก พอ replay มันจะคืนค่าเดิมที่บันทึกไว้ ทำให้โค้ดเก่ากับใหม่อยู่ในไฟล์เดียวกันได้

v := workflow.GetVersion(ctx, "changeID", minSupported, maxSupported)
  • changeID — ชื่อเฉพาะของการแก้ครั้งนี้ ต้องไม่ซ้ำกับครั้งอื่น
  • minSupported — เวอร์ชันเก่าสุดที่ยังรองรับ (workflow.DefaultVersion = -1)
  • maxSupported — เวอร์ชันปัจจุบัน
  • คืน maxSupported สำหรับ execution ใหม่, คืนค่าที่บันทึกไว้ตอน replay

ขั้นที่ 1 — เพิ่ม branch ใหม่ โดยเก็บของเก่าไว้

เคสจริง: ต้องเพิ่มการตรวจ AML ก่อนโอนเงินออก

func WithdrawWorkflow(ctx workflow.Context, req WithdrawRequest) (WithdrawResult, error) {
	actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
		StartToCloseTimeout: 60 * time.Second,
	})
	var a *activities.Activities

	if err := workflow.ExecuteActivity(actCtx, a.DebitWallet, req).Get(actCtx, nil); err != nil {
		return WithdrawResult{}, err
	}

	// ธุรกรรมที่เริ่มก่อน deploy นี้ ไม่มี marker → ได้ DefaultVersion → ข้ามการตรวจ
	// ธุรกรรมใหม่ → ได้ 1 → ตรวจ AML
	v := workflow.GetVersion(ctx, "add-aml-screening", workflow.DefaultVersion, 1)
	if v != workflow.DefaultVersion {
		var screening AMLResult
		if err := workflow.ExecuteActivity(actCtx, a.ScreenAML, req).Get(actCtx, &screening); err != nil {
			return WithdrawResult{}, err
		}
		if screening.Blocked {
			return WithdrawResult{}, temporal.NewNonRetryableApplicationError(
				"blocked by AML screening", "AMLBlocked", nil)
		}
	}

	var bankRef string
	if err := workflow.ExecuteActivity(actCtx, a.TransferToBank, req).Get(actCtx, &bankRef); err != nil {
		return WithdrawResult{}, err
	}
	return WithdrawResult{BankRef: bankRef}, nil
}

ธุรกรรมเก่าจะไม่ถูกตรวจ AML

นี่คือผลที่ตามมาโดยตรงของ patching และเป็น การตัดสินใจเชิงธุรกิจ ไม่ใช่เชิงเทคนิค ถ้ากฎบังคับว่าทุกรายการต้องผ่านการตรวจ การปล่อยให้ 3,000 รายการเก่าผ่านไปคือปัญหา compliance ทางเลือกคือ: ให้ ops cancel รายการเก่าแล้วให้ลูกค้าทำใหม่, หรือตรวจย้อนหลังนอก workflow ต้องคุยกับทีม compliance ก่อน deploy อย่าตัดสินใจเองในโค้ด

ขั้นที่ 2 — ลบ branch เก่าหลังของเก่าหมด

รอจนไม่มี execution ที่ยังใช้ path เก่าเหลืออยู่ ตรวจได้จาก:

temporal workflow list --query \
  'WorkflowType = "WithdrawWorkflow" AND ExecutionStatus = "Running"'

แล้วค่อยยก minSupported ขึ้น:

// เหลือ branch เดียว แต่ยังต้องเก็บบรรทัด GetVersion ไว้
_ = workflow.GetVersion(ctx, "add-aml-screening", 1, 1)

var screening AMLResult
if err := workflow.ExecuteActivity(actCtx, a.ScreenAML, req).Get(actCtx, &screening); err != nil {
	return WithdrawResult{}, err
}

อย่าลบบรรทัด `GetVersion` ทิ้งแม้เหลือ branch เดียว

มันทำ 2 หน้าที่: ถ้ามี execution เก่าหลุดรอดมา replay มันจะ fail ทันทีอย่างชัดเจน แทนที่จะเดินผิดเงียบๆ และถ้าต้องแก้จุดเดิมอีกในอนาคต แค่เพิ่ม maxSupported ก็พอ

ขั้นที่ 3 — แก้ซ้ำที่จุดเดิม

v := workflow.GetVersion(ctx, "add-aml-screening", 1, 2)
if v == 1 {
	// ใช้ ScreenAML แบบเดิม
} else {
	// เปลี่ยนไปใช้ vendor ใหม่
}

GetVersion ในลูป

ค่าที่คืนสำหรับ changeID 1 ตัวจะถูกล็อกไว้ตลอด ถ้าเรียกในลูปโดยใช้ ID เดิม ทุกรอบจะได้ค่าเดียวกันหมด ต้องใส่เลขรอบเข้าไปด้วย

for i, item := range batch {
	v := workflow.GetVersion(ctx, fmt.Sprintf("batch-item-%d", i), workflow.DefaultVersion, 1)
	// ...
}

Patching ทำให้โค้ดรกเร็วมาก

workflow ที่ผ่านการแก้ 10 ครั้งจะมี GetVersion 10 จุด อ่านยากและเทสยาก ถ้าทีมต้องแก้ workflow บ่อย ควรมองไปที่ Worker Deployment Versioning ตั้งแต่ต้น

วิธีที่ 2: Workflow Type ใหม่

แทนที่จะ patch ให้สร้าง type ใหม่ไปเลย ของเก่ารันจนจบด้วยโค้ดเก่า ของใหม่ใช้โค้ดใหม่

func WithdrawWorkflow(ctx workflow.Context, req WithdrawRequest) (WithdrawResult, error) {
	// v1 — ห้ามแตะ จนกว่าจะไม่มี execution เหลือ
}

func WithdrawWorkflowV2(ctx workflow.Context, req WithdrawRequest) (WithdrawResult, error) {
	// v2 — เขียนใหม่ได้อิสระเต็มที่
}
w.RegisterWorkflow(workflows.WithdrawWorkflow)
w.RegisterWorkflow(workflows.WithdrawWorkflowV2)

แล้วชี้ให้ฝั่ง API เริ่ม execution ใหม่ด้วย WithdrawWorkflowV2

ข้อดีข้อเสีย
โค้ดใหม่สะอาด ไม่มี branch ปนกันต้อง deploy worker ที่ register ทั้ง 2 type
เขียนใหม่ทั้งหมดได้เลยต้องแก้ฝั่ง caller ให้ชี้ type ใหม่
เทสแยกกันชัดเจนชื่อ type สะสมไปเรื่อยๆ (V2, V3, ...)

เหมาะกับการรื้อใหญ่ ไม่เหมาะกับการแก้เล็กน้อยบ่อยๆ

วิธีที่ 3: Worker Deployment Versioning

2 วิธีแรกจัดการเวอร์ชันในโค้ด วิธีนี้จัดการที่ระดับ deployment — ปล่อย worker หลายเวอร์ชันพร้อมกัน แล้วให้ server เป็นคนตัดสินว่า execution ไหนควรไปลงที่เวอร์ชันใด

w := worker.New(c, "wallet-core", worker.Options{
	DeploymentOptions: worker.DeploymentOptions{
		UseVersioning: true,
		Version: worker.WorkerDeploymentVersion{
			DeploymentName: "wallet-service",
			BuildId:        os.Getenv("BUILD_ID"), // git sha หรือเลขเวอร์ชัน
		},
		DefaultVersioningBehavior: workflow.VersioningBehaviorPinned,
	},
})

PINNED กับ AUTO_UPGRADE

VersioningBehaviorPinnedVersioningBehaviorAutoUpgrade
execution ที่เริ่มแล้วอยู่กับ worker เวอร์ชันเดิมจนจบย้ายไปเวอร์ชันใหม่ได้
ต้องใช้ GetVersion ไหมไม่ต้องต้อง — เพราะย้ายเวอร์ชันได้
เหมาะกับworkflow สั้น (นาที–ชั่วโมง), ธุรกรรมการเงินworkflow ยาว (สัปดาห์–เดือน) ที่ต้องได้ bug fix ระหว่างทาง
ต้อง keep worker เก่าไว้ใช่ จนกว่า execution เก่าจะหมดไม่ต้อง

ตั้งรายตัวได้ต่อ workflow เมื่อ default ไม่เหมาะ:

// ธุรกรรมเติมเงินสั้น — pin ไว้เลย
w.RegisterWorkflowWithOptions(workflows.TopUpWorkflow, workflow.RegisterOptions{
	VersioningBehavior: workflow.VersioningBehaviorPinned,
})

// Entity workflow ของบัญชี wallet อยู่ยาวเป็นปี pin ไม่ได้
w.RegisterWorkflowWithOptions(workflows.WalletAccountWorkflow, workflow.RegisterOptions{
	VersioningBehavior: workflow.VersioningBehaviorAutoUpgrade,
})

AUTO_UPGRADE ไม่ได้แปลว่าเลิกใช้ GetVersion

execution ที่ย้ายข้ามเวอร์ชันยังต้อง replay history เดิมผ่านโค้ดใหม่อยู่ดี GetVersion จึงยังจำเป็นทุกครั้งที่แก้แบบ breaking AUTO_UPGRADE แค่ทำให้ไม่ต้องเลี้ยง worker เก่าไว้ตลอดกาล

เปลี่ยนเวอร์ชันปัจจุบัน

# ดูสถานะ deployment
temporal worker deployment describe --deployment-name wallet-service

# ปล่อยทีละน้อยก่อน (canary) — ส่ง execution ใหม่ไปเวอร์ชันใหม่บางส่วน
temporal worker deployment set-ramping-version \
  --deployment-name wallet-service \
  --build-id v2.0.0 \
  --percentage 5

# พอใจแล้วค่อยสลับทั้งหมด
temporal worker deployment set-current-version \
  --deployment-name wallet-service \
  --build-id v2.0.0

ข้อดีที่สำคัญที่สุดสำหรับระบบการเงินคือ rollback ทำได้ทันที — สั่ง set-current-version กลับไปที่ build เดิม execution ใหม่ก็ไหลกลับไปเวอร์ชันเก่า โดยไม่ต้อง deploy โค้ดใหม่

temporal workflow list --query \
  'TemporalWorkerDeploymentVersion = "wallet-service:v1.0.0" AND ExecutionStatus = "Running"'

ใช้คำสั่งนี้ดูว่ายังมีใครค้างอยู่บนเวอร์ชันเก่าไหม ก่อนจะปิด worker เก่า

เลือกวิธีไหนดี

ในทางปฏิบัติของระบบ wallet มักใช้ผสมกัน:

  • ธุรกรรมสั้น (top-up, withdraw) → Worker Versioning แบบ PINNED
  • Entity workflow ของบัญชี → AUTO_UPGRADE + GetVersion ทุกครั้งที่แก้
  • รื้อ flow ใหม่ทั้งหมด → Workflow Type ใหม่

ขั้นตอน deploy ที่ปลอดภัย

  1. ตัดสินก่อนว่าการแก้นี้ breaking ไหม — ถ้าไม่แน่ใจ ให้ถือว่า breaking
  2. รัน replay test กับ fixture ทุกไฟล์ (บทที่ 9) — ต้องผ่านทั้งหมด
  3. ถ้าใช้ GetVersion ให้เพิ่ม fixture ของ path ใหม่เข้า testdata ด้วย
  4. Deploy worker ใหม่โดยยังไม่ปิด worker เก่า
  5. ดู metric temporal_workflow_task_execution_failed ว่าไม่พุ่ง (ดูบทที่ 11)
  6. ตรวจ execution ที่ค้างบนเวอร์ชันเก่าด้วย temporal workflow list
  7. ปิด worker เก่าเมื่อไม่มี execution เหลือ

`temporal workflow reset` ไม่ใช่ทางแก้ non-determinism

ถ้า deploy พังแล้วมี workflow ค้าง สิ่งที่ควรทำคือ rollback ก่อน ให้ธุรกรรมเดินต่อได้ แล้วค่อยแก้โค้ดให้ถูก การ reset ทำให้ต้องรัน activity ซ้ำจากจุด reset ซึ่งในระบบการเงินแปลว่าอาจโอนเงินซ้ำ — ทำได้ต่อเมื่อ activity idempotent จริง และต้องได้รับอนุมัติจากผู้มีอำนาจก่อนเสมอ

[!NOTE] สรุป

  • ตรรกะที่เปลี่ยนบ่อย ควรอยู่ใน activity ไม่ใช่ workflow — จะได้ไม่ต้อง version
  • GetVersion แก้ได้ทุกอย่างแต่ทำให้โค้ดรก ใช้เมื่อจำเป็น
  • Workflow Type ใหม่ เหมาะกับการรื้อใหญ่
  • Worker Deployment Versioning เป็นคำตอบที่ดีที่สุดสำหรับ workflow สั้น เพราะ ไม่ต้องแตะโค้ด workflow เลย และ rollback ได้ทันที
  • ไม่ว่าจะใช้วิธีไหน replay test คือด่านสุดท้ายที่ห้ามข้าม