2.6 · Concurrency + batch jobs
บทนี้เล่างานเบื้องหลังที่ไม่ได้ทำตอนต求 request — cron, batch, async retry, leader election แบบเถื่อน
ถ้าตอนเที่ยงคืนระบบทำงานผิดปกติ — คำตอบมักอยู่ในบทนี้
Cron jobs ในระบบ
VTRC มี cron 2 ชุด — ชุดนึงใน vtrc-api อีกชุดใน cu-central-api
vtrc-api crons
ที่มา — vtrc-api/api/src/crons.js:1-73
ใช้ node-schedule รัน in-process — คือแต่ละ replica เปิด scheduler ของตัวเอง ไม่มี external scheduler (เช่น BullMQ, Celery, Kubernetes CronJob)
| Job | Schedule (cron) | ทำอะไร | รันที่ |
|---|---|---|---|
| fund change rate | * 1 * * * * (ทุกนาที ที่ second 1) | เรียก updateSuccessFundChangeRateByJob() | เฉพาะ APP_NO === 1 |
| delegate expiry | 30 3 * * * (03:30 ทุกวัน) | updateDelegatesExpire() | ทุก replica |
| stat log | 30 0 * * * (00:30 ทุกวัน) | statLog() | ทุก replica |
| (comment ไว้) | * * 16 * * (วันที่ 16) | mass-cancel leave ของ orgUnitCD 0734 | dead code |
ระวัง — * 1 * * * * ไม่ใช่ "ทุกชั่วโมง" — node-schedule ใช้ 6-field cron (เพิ่ม field วินาที) ดังนั้นนี่คือ "ทุกนาที ที่ second 1" ไม่ใช่ "นาที 1 ของทุกชั่วโมง"
cu-central-api crons
ที่มา — cu-central-api/main-api/src/cron.js:1-32
รันเฉพาะ APP_NO === '1' (:9)
| Job | Schedule | ทำอะไร |
|---|---|---|
| updateUserId | 30 3 * * * (03:30) | new UserIdsGenerator().run() |
| conicleTransferLongTerm | 40 3 * * * (03:40 — comment เขียน 03:45 แต่แท้จริงแล้ว 03:40) | SFTP push รายชื่อพนักงานไป Conicle |
| hisTransferLongTerm | 8 * * * * (ชั่วโมงละครั้ง ที่นาที 8) | ส่งข้อมูล heal-card discount ไป HIS |
"Leader election" แบบ env var
ไม่มี Redis lock ไม่มี database lease — ใช้แค่ APP_NO === 1 เพื่อบอกว่า "ฉันคือ leader"
if (APP_NO === 1) {
schedule.scheduleJob('* 1 * * * *', updateSuccessFundChangeRateByJob)
}ผลคือ
- ถ้า replica #1 down → cron ไม่ทำงานทั้งระบบ จนกว่า #1 จะกลับมา
- ถ้ามีคน start container #1 ซ้ำ (สอง instance) → cron รัน 2 รอบ → data corruption ได้
- ไม่มี alarm — ถ้า cron ล้มเหลว ไม่มีใครรู้
ถ้าทำ modernization — เปลี่ยนเป็น BullMQ + Redis lock หรือ Kubernetes CronJob จะปลอดภัยกว่า
In-process scheduler — ความเสี่ยง
เพราะใช้ node-schedule in-process ทุก replica →
- ถ้า replica restart ระหว่าง cron → job นั้นหายไป (no retry)
- ถ้า event loop ค้าง → job ล่าช้าได้
- ถ้า process crash → ไม่มี resume
import './crons' (vtrc-api/api/src/index.js:18) เป็น side-effect import — เริ่มต้น cron ตอน module load ไม่มีทางปิด runtime
Async retry — แค่ที่ centralize bridge
vtrc-api/api/src/centralize/controllers/graphql.js:10-69 — retry 3 ครั้งตอน token expire
function GraphqlCDWithToken(query, token, variables, i = 0, name, apiKeyCD) {
...
} catch (error) {
if (error instanceof TokenExpiredError) {
if (i < 3) {
// refresh token
// save new tokenCD to db2
return GraphqlCDWithToken(query, newToken, variables, i + 1, name, apiKeyCD)
}
responError(errorCode) // throw
}
}
}retry counter เป็นพารามิเตอร์ตำแหน่งที่ 4 — ถ้า caller ลืมส่ง → default เป็น 0 → retry ได้ 3 ครั้ง
ไม่มี circuit breaker — ถ้า cu-central-api ล่ม vtrc-api จะลอง refresh token ได้สูงสุด 3 ครั้งต่อ request (เฉพาะกรณี TokenExpiredError) ส่วน network error / 5xx จะ fail ทันที → เปลือง resource และไม่มี fallback
ที่อื่นที่ "พยายามเป็น batch"
KBANK file exchange
ไม่ใช่ cron แต่เป็น manual workflow (ดู บท 2.4)
HR เป็นคน trigger ทั้ง export และ import — ระบบไม่ได้ช่วยอัตโนมัติ
Conicle SFTP transfer
อันนี้เป็น cron จริง — 03:40 ทุกวัน
cu-central-api/main-api/src/lib/conicle/conicleConverter.js:23-122
1. query พนักงานทั้งหมด (limit 999,999)
2. generate .xlsx ไฟล์ (พนักงาน + org unit)
3. SFTP upload ไป Conicle server
4. log ผลใน Models.ConicleFileLogระวัง — /api/conicle/longterm endpoint (cu-central-api/main-api/src/routes.js:11-19) ไม่มี auth ใครก็ trigger SFTP push ได้ตลอดเวลา
Concurrency ที่ implicit
จุดที่มี concurrency เกิดขึ้นโดยไม่ได้ตั้งใจ
1. หลาย replica query DB เดียวกัน
4 replicas ของ vtrc-api ทุกตัว query MariaDB schema vtrc พร้อมกันได้ — ถ้า update เกิดพร้อมกัน → optimistic locking ไม่มี ใช้ last-write-wins
2. PDF generation thread pool
vtrc-api/api/src/routes.js:73, 120, 227, 277, 355 — ใช้ execSync('qpdf --encrypt ...') — synchronous blocking call ที่จับ event loop ทั้ง thread
ถ้ามี request ที่ต้อง generate PDF พร้อมกันหลาย request → ทุก request อื่นรอ
3. cron + request ใน event loop เดียวกัน
เพราะ cron รัน in-process → ตอน 03:30 ที่ cron ทำงาน request ปกติจะช้าลง
"Concurrency" ที่ไม่ได้เป็นจริง
ตอนอ่านโค้ดจะเจอ Promise.all ในหลายที่ — แต่ promise ไม่ได้แปลว่า parallel จริง เพราะ Node.js event loop single-threaded → แค่ context switch ไม่ได้รัน CPU-bound พร้อมกัน
ตัวอย่าง — centralize-api/src/modules/technicalPosition/technicalPosition.repository.ts:19-22
const [center, nbc] = await Promise.all([
TechnicalPosition.findBy(query),
TechnicalPositionNBC.findBy(query)
])นี่คือ parallel I/O จริง (เพราะเป็น DB query async) — แต่ CPU time ยังอยู่บน thread เดียว
เคล็ดลับตอนทำงานจริง
ถ้าทำงานช้าตอน 03:30 หรือ 00:30
เพราะ cron รันพร้อมกัน → ใช้ resource มาก → request ปกติช้าลง
วิธีตรวจ — ดูว่าช่วงเวลานั้นมี cron อะไรรัน (ตารางด้านบน) → ถ้าใช่ ให้ schedule cron ใหม่ให้กระจาย
ถ้า cron "หายไป"
อย่าคิดว่ามันไม่ทำงาน — เช็คก่อนว่า
APP_NOของ container #1 ถูกตั้งไว้ถูกไหม (vtrc-api/docker-compose.yml:50)- container #1 restart ล่าสุดเมื่อไหร่ — ถ้า restart ระหว่าง cron ตัวนั้นหาย
- มี error ใน log ไหม (
vtrc-api/api-logs/logs/...)
อย่าเพิ่ม cron ใหม่โดยไม่คิดเรื่อง leader
ถ้าเขียน cron ใหม่ใน crons.js แล้วไม่ใส่ APP_NO === 1 guard → มันจะรัน 4 รอบ (ทุก replica) → data corruption
กฎ — cron ที่ write data = APP_NO === 1, cron ที่ read-only = รันทุก replica ได้
อย่าใช้ execSync ใน hot path
vtrc-api/api/src/routes.js:73 (qpdf) เป็นตัวอย่างที่ไม่ดี — จับ event loop ทั้ง thread
ถ้าทำ refactoring — ย้ายไป worker thread หรือเปลี่ยนเป็น child_process.exec (async) + queue