Skip to content

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

javascript
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)JobAPP_NO gateบรรทัด
1* 1 * * * * (ทุกนาทีที่ :01s)updateSuccessFundChangeRateByJobAPP_NO === 1 เท่านั้นcrons.js:14-18
230 3 * * * (03:30 ทุกวัน)updateDelegatesExpireทุก replicacrons.js:20-22
330 0 * * * (00:30 ทุกวัน)statLogทุก replicacrons.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

javascript
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

javascript
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

javascript
// 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

javascript
// 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

javascript
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-processBullMQ + Redis (distributed) หรือ k8s CronJob
env var APP_NO gateRedis Redlock สำหรับ leader election
fire-and-forgetjob status table + retry + dead-letter queue
log แค่ใน consolestructured log + alerting (Prometheus / Sentry)
cron ทุก replicacron ที่ 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 ก่อน


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

ไป 3.1.9 Cross-cutting concerns