แก้ปัญหา race condition ของการ run database migration ใน integration test ของระบบภาษา Go
เมื่ออาทิตย์ก่อนได้มีโอกาสไปแบ่งปันประสบการณ์ ว่าด้วยเรื่องของ software engineering practice ผ่านการพัฒนาระบบด้วยภาษา Go ซึ่งเป็นส่วนนึงของ workshop 2 วัน เพื่อให้เห็นภาพของขั้นตอนการส่งมอบ software ว่าเป็นอย่างไรบ้าง จากนั้นทำการออกแบบระบบตาม requirement ทำความรู้จักเครื่องมือต่าง ๆ จากนั้นก็ลง workshop ต่อไป ซึ่งแน่นอน database migration ที่ใช้งานผ่านเครื่องมือ goose ก็เป็นหนึ่งในนั้น
จากบทความก่อนที่เคยแบ่งปันไป พอมาลง workshop ปรากฎว่ามีอยู่กุล่มนึงเกิดปัญหาที่น่าสนใจระหว่างการเขียน integration test ขึ้น
Integration test
Dockerfile
Dockerfile
สำหรับเอาไว้ build Go Docker container โดยติดตั้ง dependencies และ run คำสั่งผ่าน Go CLI
Docker Compose
docker-compose.it.test.yaml
สำหรับเอาไว้ run integration test ที่เชื่อมต่อกับ PostgreSQL ที่ run บน Docker container
ปรากฏว่าพอ run บน local machine ก็ผ่านปกติ
docker-compose -f docker-compose.it.test.yaml down && \
docker-compose -f docker-compose.it.test.yaml up --build --force-recreate --abort-on-container-exit --exit-code-from it_tests
แต่พอไป run บน CI แล้ว error ว่าหา database table ไม่เจอ แต่อีก test case นึงดันหาเจอ
it_tests_1 | Error: Not equal:
it_tests_1 | expected: 201
it_tests_1 | actual : 500
db_1 | ERROR: relation "..." does not exist at character 13
db_1 | STATEMENT: INSERT INTO "..." (...) VALUES (...) RETURNING id;
ทีนี้สาเหตุคือ Go จะ run test แบบ concurrent หากมันอยู่คนละ package กัน เช่นเดียวกับการ compile แบบ concurrent หากอยู่คนละ package เพราะหน่วยที่เล็กที่สุดของ Go คือ package
แปลว่า test case ที่ run เสร็จก่อนจะไป rollback migration ตามที่ได้เขียนไว้ว่า defer migration.rollbackMigration(...)
ก่อน ทำให้ จังหวะที่ test case อีกอันที่ run อยู่แล้วเข้า statement ในการ query ของ database จะหา table ไม่เจอ (เรียกเหตุการณ์นี้ว่า race condition)
วิธีแก้ก็คือแทนที่จะ rollback ก็ลบ record ที่เกิดจากการสร้างของแต่ละ test case แต่ข้อควรระวังคือ ห้าม truncate table! เพราะข้อมูลของอีก test case นึงที่กำลังจะใช้ก็อาจจะหายไปด้วย
ข้อสังเกตอีกอย่างนึงคือเราควรจะเปลี่ยนจากการใช้ defer
เป็น t.Cleanup
แทนเพราะอย่างหัลงมันเจาะจงไปที่ testing มากกว่า
ทีนี้พอลอง run ใหม่ปรากฏว่าผ่านแล้วทั้งบน local machine และบน CI แต่พอ run รอบถัดไปก็ดันไม่ผ่านอีก! คราวนี้น่ากลัวกว่าเดิมเพราะ run ผ่านบ้างไม่ผ่านบ้าง แสดงว่ามันยังคงเกิด race condition อยู่ผ่าน statement การ migrate migration.ApplyMigrations(conn)
ซึ่งก็มีคนไปเปิด GitHub issue ไว้ด้วย ซึ่ง maintainer เขาก็ตอบกลับมาว่า
This is intended behaviour. goose is meant to be run as a singleton to apply migrations sequentially, generally this is best practice.
E.g., however your application starts up, you’d run a container, or a binary, etc. to apply your migrations and upon success continue to rollout your application.
I’d suggest decoupling your migrations from your main application.
แปลว่าวิธีแก้ที่หายขาดคือต้องเอา process การทำ database migration แยกออกจากการทำ application นั่นเอง โดยให้เราไปลบ migration.ApplyMigrations(conn)
ออก แล้วใน Dockerfile.it
ให้เราไปติดตั้ง database migration tool เพิ่ม ซึ่งก็คือ goose
นั่นเอง โดยเวลาเราติดตั้งนั้น
- ถ้าอยากได้ executable ต้องติดตั้งผ่านคำสั่ง
go install
ของที่ติดตั้งจะอยู่ใน/go/bin
- ถ้าอยากได้ package ต้องติดตั้งผ่านคำสั่ง
go get
ของที่ติดตั้งจะอยู่ใน/go/pkg
ในกรณีนี้เราเลยต้องใช้ go install
เพราะเราจะได้ใช้ goose CLI ได้
ทีนี้พอเรา run ใหม่ก็เป็นผ่านตลอดแล้ว เพราะ process การทำ database migration นั้นเกิดก่อน integration test จึงการันตีได้ว่าจะไม่เกิด race condition แน่ ๆ
แน่นอนว่าเราต้องปรับแก้ script ในการ deploy ให้ database migration เป็น process แยกอีกทีด้วยเช่นกัน