Skip to content

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 ที่ติดมา)

14:18:vtrc-api/api/src/crons.js
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)JobAPP_NO gateบรรทัด
1* 1 * * * * (every minute at :01s)updateSuccessFundChangeRateByJobAPP_NO === 1 เท่านั้นcrons.js:15-17
230 3 * * * (03:30 ทุกวัน)updateDelegatesExpireทุก replicacrons.js:20-22
330 0 * * * (00:30 ทุกวัน)statLogทุก replicacrons.js:25-27
4-5commented out(เคยเป็น auto-cancel leave cron)-crons.js:29-70

Job #1 — updateSuccessFundChangeRateByJob

ทุกนาทีตอน :01s — replica #1 เท่านั้น

400:410:vtrc-api/api/src/lib/controllers/fund/fund.js
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

RisktoLocaleDateString() compare เป็น string ไม่ใช่ date compare ถ้า server timezone กับที่ insert ไว้ต่างกัน จะ match ไม่ติด → rate change ไม่เกิด

Job #2 — updateDelegatesExpire

ทุกวันเวลา 03:30 — ทุก replica

926:928:vtrc-api/api/src/lib/controllers/fund/fund.js
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

8:8:vtrc-api/api/src/lib/controllers/statLog/statLog.js
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

22:28:vtrc-api/api/src/lib/controllers/statLog/statLog.js
  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)

29:70:vtrc-api/api/src/crons.js
// 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)

79:82:vtrc-api/api/src/routes.js
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-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 ไม่รัน — เช็กตามลำดับ

  1. APP_NO env var ของ replica ที่คาดว่าจะรัน — ตั้งถูกไหม
  2. log ใน crons.js — scheduleJob register สำเร็จไหม
  3. cron syntax — ลอง test บน https://crontab.guru/
  4. 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 ก่อน


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

ไป บท 3.9 Cross-cutting concerns