3.1.8 · Cron + background jobs
บทนี้เล่าว่า vtrc-api มี background job อะไรบ้าง รันเมื่อไหร่ และ "leader election" ที่ใช้ env var APP_NO ทำงานอย่างไร ทุก citation re-verify ตรงกับ crons.js (72 บรรทัด) บนดิสก์ — ตรงกับ v1 เหมือนกันทุกประการ
ภาพรวม — node-schedule in-process
vtrc-api ไม่มี external scheduler (ไม่มี Bull, BullMQ, Redis queue, k8s CronJob) — ใช้ node-schedule library รันใน process เดียวกับ ApolloServer
api/src/crons.js (72 บรรทัด)
├── 3 cron jobs active ตอน import (side-effect)
├── import ครั้งแรกจาก index.js:18 ('./crons')
└── cron syntax แบบ * * * * * * (6 fields — second minute hour day month weekday)node-schedule เป็น in-memory — ถ้า process restart job หายทั้งหมด ไม่มี persistence ไม่มี retry
APP_NO — "leader election" ผ่าน env var
VTRC รัน vtrc-api หลาย replicas — แต่ cron บางตัวต้องรันแค่ที่ replica เดียว ไม่งั้นซ้ำ วิธีแก้ — env var APP_NO ที่แต่ละ replica ตั้งค่าต่างกัน (1, 2, 3, 5 — ไม่มี 4 เป็น typo ที่ติดมา)
vtrc-api/api/src/crons.js:14-18
if (+APP_NO === 1) {
jobs.push(schedule.scheduleJob('* 1 * * * *', async () => {
await updateSuccessFundChangeRateByJob()
}))
}replica ที่ APP_NO=1 เท่านั้นที่รัน fund rate change job replica อื่นข้าม if block
ปัญหา — manual leader election
ถ้า replica #1 down → cron หายทั้งระบบ ไม่มี fallback ไม่มี takeover ถ้า admin start replica #1 ตัวที่สอง → cron รันซ้อน 2 ตัว ถ้า env var ผิด → cron ไม่รันเลย สิ่งที่ควรจะเป็น — distributed lock ผ่าน Redis (Redlock) หรือ database row lock
3 cron jobs ที่ register (active)
| # | Schedule (cron) | Job | APP_NO gate | บรรทัด |
|---|---|---|---|---|
| 1 | * 1 * * * * (ทุกนาทีที่ :01s) | updateSuccessFundChangeRateByJob | APP_NO === 1 เท่านั้น | crons.js:14-18 |
| 2 | 30 3 * * * (03:30 ทุกวัน) | updateDelegatesExpire | ทุก replica | crons.js:20-22 |
| 3 | 30 0 * * * (00:30 ทุกวัน) | statLog | ทุก replica | crons.js:25-27 |
| 4 (commented) | เดือนละครั้งวันที่ 16 | (เคยเป็น auto-cancel leave cron) | - | crons.js:29-70 |
Job #1 — updateSuccessFundChangeRateByJob
ทุกนาทีตอน :01s — replica #1 เท่านั้น อ่าน MasterConfig หาวันที่ effective ของ ProvidentFund ถ้าตรงวันนี้ apply rate change ให้ fund document ที่ status success
vtrc-api/api/src/lib/controllers/fund/fund.js:400
export const updateSuccessFundChangeRateByJob = async () => {Risk — เทียบ date ด้วย toLocaleDateString() เป็น string compare ไม่ใช่ date compare ถ้า server timezone ต่างจากที่ insert ไว้ → match ไม่ติด → rate change ไม่เกิด
Job #2 — updateDelegatesExpire
ทุกวันเวลา 03:30 — ทุก replica
vtrc-api/api/src/lib/controllers/fund/fund.js:926-928
export const updateDelegatesExpire = () => {
Models.db.query(`UPDATE vtrc.Delegates SET isActive = :isActive WHERE NOW() > endDate and isActive != 0`, { replacements: { isActive: 2 }, type: QueryTypes.UPDATE })
}ข้อดี — query ใช้ parameterized :isActive ไม่มี SQL injection risk ข้อเสีย — fire-and-forget แบบสุดๆ: function ไม่ async ไม่ return promise, caller (crons.js:21) ไม่ await, ถ้า query fail → unhandledRejection ที่ไม่มีใครเห็น, ถ้า affected rows = 0 ไม่มี log/alert
Job #3 — statLog
ทุกวันเวลา 00:30 — ทุก replica ดึง activation/activated/usage log จาก END_POINT_LOG_EVENT (3 endpoint แยก) → join กับ cu-central-api (ผ่าน statLogCD) → bulk INSERT ลง 3 ตารางใน db2 (vtrc-centralize schema): reportActivateAccount, reportActivatedAccount, reportModuleUsage
Risk — SQL injection ผ่าน string concat
// statLog/statLog.js (pattern)
let empCodeData = "";
for(var i = 0; i < logEventActiveData.length; i++) {
empCodeData += "'"+logEventActiveData[i].param+"'";
if(i != logEventActiveData.length - 1){
empCodeData += ', '
}
}ค่า param ที่ได้จาก END_POINT_LOG_EVENT ถูก concat เข้ากับ SQL string โดยตรง (ใน 3 block ของไฟล์) แล้วเอาไป query + insert แบบ raw query ถ้า log service ถูก compromise หรือส่งค่าที่มี ' มา → SQL injection ที่ db2
Job #4 — commented out (เคยเป็น leave auto-cancel)
vtrc-api/api/src/crons.js:29-70
// let rule = new schedule.RecurrenceRule();
// rule.month = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
// rule.date = 16;
// ...เคยเป็น cron auto-cancel leave document ของ org 0734 (HR HQ) ทุกวันที่ 16 ของเดือน — ถูก comment ออก เหตุผลไม่ชัด อาจเป็นเพราะ business rule เปลี่ยน
Cron pattern ที่ควรจะเป็น — checklist
ถ้าจะเขียน cron ใหม่ (ปัจจุบันไม่มี cron ไหนทำครบ)
- ใส่ try/catch รอบ
await job()แล้ว log error ที่ catch - log start + end + duration ของแต่ละรอบ
- มี APP_NO gate หรือ distributed lock เพื่อกันรันซ้ำใน multi-replica
- มี timeout (ถ้า job ค้าง ไม่ควร block schedule รอบถัดไป)
- มี alerting ถ้า job fail ติดต่อกัน N รอบ
Background jobs ที่ไม่ใช่ cron
1. PDF delete (setTimeout)
vtrc-api/api/src/routes.js:79-82
setTimeout(() => {
let pathFile = `${allData.pathfilePDF}/${allData.file}.pdf`
deletePDF(pathFile)
}, 5000);หลังส่ง PDF 5 วินาที → delete ไฟล์ออกจาก disk เป็น cleanup ที่ใช้ setTimeout ใน event loop — ถ้า process restart ระหว่าง 5 วินาทีไฟล์ค้างไว้ (orphan)
2. Notification fan-out
lib/controllers/notification/notification.js#sendNotification ทำ fan-out ไปหลาย target (FCM, in-app, email) เป็น inline async ไม่ใช่ queue ถ้า fan-out ช้าหรือ target ล่ม → request ที่ trigger ก็ช้าด้วย
สิ่งที่ควรจะเป็น (modernization target)
| ปัจจุบัน | ควรจะเป็น |
|---|---|
node-schedule in-process | BullMQ + Redis (distributed) หรือ k8s CronJob |
| env var APP_NO gate | Redis Redlock สำหรับ leader election |
| fire-and-forget | job status table + retry + dead-letter queue |
| log แค่ใน console | structured log + alerting (Prometheus / Sentry) |
| cron ทุก replica | cron ที่ leader เท่านั้น (retry บน replica อื่นถ้า fail) |
เคล็ดลับตอนทำงานจริง
ถ้า cron ไม่รัน — เช็กตามลำดับ
APP_NO env var ของ replica ที่คาดว่าจะรัน → log ใน crons.js scheduleJob register สำเร็จไหม → cron syntax ทดสอบบน crontab.guru → process restart (scheduleJob ไม่ persist ถ้า process ตายกลางคัน)
ถ้าเจอ delegate ไม่ expire ทั้งที่ endDate ผ่าน
updateDelegatesExpire รัน 03:30 — เช็กว่าเลยเวลานั้นแล้ว, timezone ของ NOW() ต้องตรงกับที่ใส่ใน endDate, ถ้า query fail จะไม่มี log (fire-and-forget)
อย่าเพิ่ม cron ทุก replica
ถ้า cron ทำ write ต้องมี APP_NO gate หรือ lock ไม่งั้น data ซ้ำ
cron ที่ commented ออก — อย่า uncomment โดยไม่เช็ก
อาจถูก comment เพราะ business rule เปลี่ยน ก่อน uncomment ถามทีม HR/i3 ก่อน