3.8 · Cron + background jobs
บทนี้สั้น — เล่าว่า vtrc-api มี background job อะไรบ้าง รันเมื่อไหร่ และ "leader election" ที่ใช้ env var APP_NO ทำงานอย่างไร
ภาพรวม — node-schedule in-process
vtrc-api ไม่มี external scheduler (ไม่มี Bull, BullMQ, Redis queue, k8s CronJob) — ใช้ node-schedule library ที่รันใน process เดียวกับ ApolloServer
api/src/crons.js (72 บรรทัด)
├── 5 cron jobs ที่ register ตอน 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 (ปกติ 4 ตัว) — แต่ cron บางตัวต้องรันแค่ที่ replica เดียว ไม่งั้นจะซ้ำ
วิธีแก้ — env var APP_NO ที่ replica แต่ตัวตั้งค่าต่างกัน (1, 2, 3, 5 — ไม่มี 4 เป็น typo ที่ติดมา)
if (+APP_NO === 1) {
jobs.push(schedule.scheduleJob('* 1 * * * *', async () => {
await updateSuccessFundChangeRateByJob()
}))
}- replica ที่
APP_NO=1เท่านั้นที่รัน fund rate change job - replica อื่น (2, 3, 5) ข้าม if block → ไม่ register job นี้
ปัญหา — manual leader election
วิธีนี้ใช้งานได้ แต่มี risk
- ถ้า replica #1 down → cron หายทั้งระบบ ไม่มี fallback ไม่มี takeover
- ถ้า admin start replica #1 ตัวที่สอง (เช่น scale up) → cron รันซ้อน 2 ตัว
- ถ้า env var ผิด → cron ไม่รันเลย
สิ่งที่ควรจะเป็น — distributed lock ผ่าน Redis (Redlock) หรือ database row lock ที่ elect leader แบบ real
5 cron jobs ที่ register
| # | Schedule (cron) | Job | APP_NO gate | บรรทัด |
|---|---|---|---|---|
| 1 | * 1 * * * * (every minute at :01s) | updateSuccessFundChangeRateByJob | APP_NO === 1 เท่านั้น | crons.js:15-17 |
| 2 | 30 3 * * * (03:30 ทุกวัน) | updateDelegatesExpire | ทุก replica | crons.js:20-22 |
| 3 | 30 0 * * * (00:30 ทุกวัน) | statLog | ทุก replica | crons.js:25-27 |
| 4-5 | commented out | (เคยเป็น auto-cancel leave cron) | - | crons.js:29-70 |
Job #1 — updateSuccessFundChangeRateByJob
ทุกนาทีตอน :01s — replica #1 เท่านั้น
export const updateSuccessFundChangeRateByJob = async () => {
try {
const conditionMasterConfigProv = {
configGroup: "ProvidentFund"
}
const provEffectedDate = await MasterConfig.find({ where: { ...conditionMasterConfigProv, configType: "ProvidentFundEffectiveDate" } })
const effectDate = new Date(provEffectedDate.configValue1)
const curretnDate = new Date()
if (effectDate.toLocaleDateString() == curretnDate.toLocaleDateString()) {อ่าน MasterConfig หาวันที่ effective ของ ProvidentFund — ถ้าตรงวันนี้ จะทำการ apply rate change ให้กับ fund document ทั้งหมดที่ status success
Risk — toLocaleDateString() compare เป็น string ไม่ใช่ date compare ถ้า server timezone กับที่ insert ไว้ต่างกัน จะ match ไม่ติด → rate change ไม่เกิด
Job #2 — updateDelegatesExpire
ทุกวันเวลา 03:30 — ทุก replica
export const updateDelegatesExpire = () => {
Models.db.query(`UPDATE vtrc.Delegates SET isActive = :isActive WHERE NOW() > endDate and isActive != 0`, { replacements: { isActive: 2 }, type: QueryTypes.UPDATE })
}อัปเดต delegate ที่หมดอายุ (NOW > endDate) ให้ isActive = 2
ข้อดี — query ใช้ parameterized :isActive → ไม่มี SQL injection risk ข้อเสีย — fire-and-forget แบบสุดๆ:
- function ไม่ async และไม่ return promise → caller (
crons.js:21) ไม่มีawait - ถ้า query fail → reject กลายเป็น unhandledRejection → ไม่มีใครเห็น
- ถ้า affected rows = 0 → ไม่มี log / alert
- debug "ทำไม delegate ยัง active ทั้งที่ endDate ผ่าน" ยากมาก
Job #3 — statLog
ทุกวันเวลา 00:30 — ทุก replica
export const statLog = async () => {ดึง 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
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 — :22-28, :99-104, :175-180) แล้วเอาไป query + insert แบบ raw query ที่ :39, :75, :115, :151, :191, :227
ถ้า log service ถูก compromise หรือส่งค่าที่มี ' มา → SQL injection ที่ db2 (เป็น schema กลางที่ใช้ร่วมกับ centralize)
Job #4-5 — commented out (เคยเป็น leave auto-cancel)
// let rule = new schedule.RecurrenceRule();
// rule.month = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
// rule.date = 16;
// rule.hour = 0;
// rule.minute = 1;
// jobs.push(schedule.scheduleJob(rule, async () => {
// const empCodeList = await getEmpCodeWithOrgUnitCD("0734");
// const docs = await Models.LeaveDocument.findAll({...})
// for (let item of docs) {
// // auto-cancel leave document
// }
// }))เคยเป็น cron ที่ auto-cancel leave document ของ org 0734 (HR HQ) ทุกวันที่ 16 ของเดือน — แต่ถูก comment ออก เหตุผลไม่ชัด อาจจะเป็นเพราะ business rule เปลี่ยน
Cron pattern ที่ควรจะเป็น — checklist
ถ้าจะเขียน cron ใหม่ ควรทำตาม checklist นี้ (ปัจจุบันไม่มี 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
มีงาน async อีก 2 ประเภทที่ไม่ใช่ cron
1. PDF delete (setTimeout)
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 notification ก็ช้าด้วย
สิ่งที่ควรจะเป็น (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_NOenv var ของ replica ที่คาดว่าจะรัน — ตั้งถูกไหม- log ใน
crons.js— scheduleJob register สำเร็จไหม - cron syntax — ลอง test บน https://crontab.guru/
- process restart — scheduleJob ไม่ persist ถ้า process ตายกลางคัน
ถ้าเจอ delegate ไม่ expire ทั้งที่ endDate ผ่าน
updateDelegatesExpireรัน 03:30 — เช็กว่าเลยเวลานั้นไปแล้วUPDATE ... WHERE NOW() > endDate— timezone ของ NOW() ต้องตรงกับที่ใส่ใน endDate- ถ้า query fail จะไม่มี log (fire-and-forget)
อย่าเพิ่ม cron ทุก replica
ถ้า cron ทำ write (insert/update/delete) — ต้องมี APP_NO gate หรือ lock ไม่งั้น data ซ้ำ / สับสน
cron ที่ commented ออก — อย่า uncomment โดยไม่เช็ก
commented cron ใน crons.js:29-70 อาจจะถูก comment เพราะ business rule เปลี่ยน ก่อน uncomment ถาม HR/i3 ก่อน