บทที่ 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 ไหม |
|---|---|
| เพิ่ม/ลบ/สลับลำดับ การเรียก activity | breaking |
| เพิ่ม/ลบ timer, child workflow, signal channel | breaking |
เปลี่ยนเงื่อนไข if ที่ทำให้เดินคนละ branch | breaking |
| เปลี่ยนชื่อ activity หรือชื่อ workflow type | breaking |
เปลี่ยน 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
VersioningBehaviorPinned | VersioningBehaviorAutoUpgrade | |
|---|---|---|
| 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 ที่ปลอดภัย
- ตัดสินก่อนว่าการแก้นี้ breaking ไหม — ถ้าไม่แน่ใจ ให้ถือว่า breaking
- รัน replay test กับ fixture ทุกไฟล์ (บทที่ 9) — ต้องผ่านทั้งหมด
- ถ้าใช้
GetVersionให้เพิ่ม fixture ของ path ใหม่เข้า testdata ด้วย - Deploy worker ใหม่โดยยังไม่ปิด worker เก่า
- ดู metric
temporal_workflow_task_execution_failedว่าไม่พุ่ง (ดูบทที่ 11) - ตรวจ execution ที่ค้างบนเวอร์ชันเก่าด้วย
temporal workflow list - ปิด 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 คือด่านสุดท้ายที่ห้ามข้าม