Skip to content

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)

JobSchedule (cron)ทำอะไรรันที่
fund change rate* 1 * * * * (ทุกนาที ที่ second 1)เรียก updateSuccessFundChangeRateByJob()เฉพาะ APP_NO === 1
delegate expiry30 3 * * * (03:30 ทุกวัน)updateDelegatesExpire()ทุก replica
stat log30 0 * * * (00:30 ทุกวัน)statLog()ทุก replica
(comment ไว้)* * 16 * * (วันที่ 16)mass-cancel leave ของ orgUnitCD 0734dead 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)

JobScheduleทำอะไร
updateUserId30 3 * * * (03:30)new UserIdsGenerator().run()
conicleTransferLongTerm40 3 * * * (03:40 — comment เขียน 03:45 แต่แท้จริงแล้ว 03:40)SFTP push รายชื่อพนักงานไป Conicle
hisTransferLongTerm8 * * * * (ชั่วโมงละครั้ง ที่นาที 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

ts
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 "หายไป"

อย่าคิดว่ามันไม่ทำงาน — เช็คก่อนว่า

  1. APP_NO ของ container #1 ถูกตั้งไว้ถูกไหม (vtrc-api/docker-compose.yml:50)
  2. container #1 restart ล่าสุดเมื่อไหร่ — ถ้า restart ระหว่าง cron ตัวนั้นหาย
  3. มี 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


ขั้นตอนถัดไป

ไป บท 2.7 Cross-cutting concerns