Skip to content

5 · Cron jobs

บทนี้เล่าว่า benefit-api มี background job อะไรบ้าง รันเมื่อไหร่ ทำอะไร และมีจุดบกพร่องแบบไหนที่ควรรู้ก่อน debug ทุก citation re-verify ตรงกับไฟล์ใน benefit-api/src/crontab/ และ benefit-api/src/modules/batchJob/ บนดิสก์ (branch: prod)

benefit-api ใช้ @nestjs/schedule (in-process cron) — ไม่มี queue/worker แยก, ไม่มี BullMQ, ไม่มี Redis, ไม่มี k8s CronJob ที่ external scheduler ใด ๆ เป็น orchestrator พบ cron job ที่ประกาศจริงด้วย decorator @Cron 2 ตัว ทั้งคู่อยู่ในไฟล์เดียวกัน (benefit-api/src/crontab/cronTab.service.ts (branch: prod):22, 37)

นี่คือ service scope งานเล็ก (batch sync ข้าม benefit schema กับ HRMI schema) — ต่างจาก vtrc-api ที่ใช้ cron หลายตัวเป็นส่วนหนึ่งของ business flow หลัก ถ้า cron ของ benefit-api fail ผลกระทบจำกัดอยู่ที่ "ข้อมูล welfare/import-update ล่าช้าวันหนึ่ง" ไม่ใช่ "ระบบล่ม"


ภาพรวม — @nestjs/schedule in-process

benefit-api/src/app.module.ts (branch: prod):23, 67 import ScheduleModule.forRoot() ที่ root module ทำให้ decorator @Cron ในทุก provider ของ module tree ทำงานได้

benefit-api/src/crontab/                    (branch: prod)
├── cronTab.module.ts        (9 บรรทัด)     // provider ลงทะเบียน CronTabService + 2 task service
├── cronTab.service.ts       (51 บรรทัด)    // จุดเดียวที่มี @Cron decorator — 2 jobs
├── funeral/
│   ├── funeral.interface.ts (98 บรรทัด)    // interface Funeral + WelfarePayment (รับค่าจาก raw SQL)
│   ├── funeral.task.module.ts
│   └── funeral.task.service.ts (576 บรรทัด) // 2 method: doImportFuneral, doUpdateFuneralForHRMI
└── inheritance/
    ├── inheritance.interface.ts (74 บรรทัด)
    ├── inheritance.task.module.ts
    └── inheritance.task.service.ts (657 บรรทัด) // 2 method: doImportInheritance, doUpdateInheritanceForHRMI

ลักษณะเด่น — CronTabModule (benefit-api/src/crontab/cronTab.module.ts (branch: prod):6-8) declare provider CronTabService, FuneralTaskService, InheritanceTaskService แบบ local ภายใน module ตัวเดียว ไม่มี exports: [] ทำให้ service เหล่านี้ "ใช้ได้เฉพาะภายใน CronTabModule" ทางทฤษฎี แต่ ManualBatchModule ก็ re-declare ทั้งสาม provider ของตัวเองอีกชุด (benefit-api/src/modules/batchJob/manualBatch.module.ts (branch: prod):8) — เป็น code duplication ที่ทำให้การแก้ task service ต้องระวังว่า DI container มี instance สองตัวหรือไม่ (NestJS จะถือว่าเป็นคนละ instance เพราะอยู่คนละ module)

@nestjs/schedule เป็น in-memory scheduler — ถ้า NestJS process restart ระหว่างที่ cron รันอยู่ job หายทันที ไม่มี persistence ไม่มี resume ต้องรอรอบ schedule ถัดไปหรือ trigger manual ผ่าน ManualBatchController


Leader election — ไม่มี

ต่างจาก vtrc-api ที่ใช้ env var APP_NO เป็น manual leader election (replica ที่ APP_NO === 1 เท่านั้นที่รัน fund rate job) — benefit-api ไม่มี leader election ของ cron เลย ถ้า deploy หลาย replicas พร้อมกัน cron ทั้งสองตัวจะรันซ้ำทุก instance

การันตีไม่ได้ว่า deployment จริงรันกี่ replica เพราะไม่พบ k8s manifest หรือ docker-compose ที่ declare replica count ใน repo (UNVERIFIED — ต้อง verify กับทีม infra) แต่ถ้า > 1 replica นี่คือ correctness bug จริงทันที เพราะทั้งสอง job เป็น write operation ไปยัง benefit schema (insert/update WelfareHeader, PaymentTransaction)

แนวทางแก้ (ไม่ได้แก้ในบทนี้):

PatternComplexityNote
env var gate เหมือน APP_NOต่ำเสี่ยงแบบเดียวกันกับ vtrc-api — ถ้า leader ล่ม job หายทั้งระบบ
Redis Redlockกลางต้องเพิ่ม Redis dependency (ปัจจุบัน repo ไม่มี Redis)
DB row lock (sp_getapplock ใน MSSQL)กลางใช้ connection main ที่มีอยู่แล้ว ไม่ต้องเพิ่ม infra
k8s CronJob + Job resourceสูงแยก cron เป็น pod ต่างหากจาก API server

รายการ cron job — ภาพรวม

#JobTriggerSchedule expressionSource method
1importDataFromHRMI22:10 ทุกวัน (เวลา server)'10 22 * * *' (raw string)benefit-api/src/crontab/cronTab.service.ts (branch: prod):22-35
2updateDataToHRMI21:00 ทุกวัน (เวลา server)CronExpression.EVERY_DAY_AT_9PM (enum)benefit-api/src/crontab/cronTab.service.ts (branch: prod):37-50

ทั้งสอง job เป็น private async method@Cron decorator ทำงานได้กับ private method ของ NestJS provider เพราะ NestJS ใช้ reflect-metadata ไม่ได้เรียกผ่าน public API

ทั้งสอง job เรียก task service แบบ sequential await (ไม่ parallel) ตามลำดับ funeral → inheritance — ทำให้ runtime รวมของ job หนึ่งเป็นผลรวมของทั้งคู่ ถ้า funeral ใช้เวลา 5 นาที inheritance ต้องรอก่อน ไม่มี Promise.all และไม่มีการ fan-out

ทั้งสอง job ส่ง null เป็น argument ของ task method (doImportFuneral(null), doUpdateFuneralForHRMI(null) ฯลฯ) — null แปลว่า "ให้ task service คำนวณ bizDate เอง" ในขณะที่ ManualBatchController ส่ง body.bizDate เข้าไปแทน


Job #1 — importDataFromHRMI (22:10 ทุกวัน)

benefit-api/src/crontab/cronTab.service.ts (branch: prod):22-35:

typescript
@Cron('10 22 * * *')
private async importDataFromHRMI() {
  console.log('CronTabService -> doImportFuneral -> Start');
  this.logger.log('CronTabService -> doImportFuneral -> Start');
  await this.funeralTaskService.doImportFuneral(null);

  console.log('CronTabService -> doImportInheritance -> Start');
  this.logger.log('CronTabService -> doImportInheritance -> Start');
  await this.inheritanceTaskService.doImportInheritance(null);
}

จุดสังเกตของ job wrapper

  • ไม่มี try/catch รอบ await ใน wrapper นี้ — ถ้า task service throw ขึ้นมา (ซึ่ง InheritanceTaskService ทำ — benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):75, 165) exception จะ propagate ขึ้นไปถึง scheduler ของ @nestjs/schedule ที่จะ catch แล้ว log เป็น Unhandled promise rejection ใน console — ไม่มี alerting ใด ๆ ออกไป
  • เรียก console.log และ this.logger.log พร้อมกันทุกบรรทัด — เป็น pattern log ซ้อนที่พบทั่ว repo (ดู 06 Scorecard OBS-BENEFIT-01) เป็น duplicate logging ที่เปลือง stdout
  • ใช้ cron string '10 22 * * *' (5 fields — standard cron syntax) ต่างจาก vtrc-api ที่ใช้ 6 fields ของ node-schedule@nestjs/schedule ใช้ cron package ที่เป็น 5 fields

What job ทำ (จาก doImportFuneral + doImportInheritance)

  1. คำนวณ bizDateWelfareUtil.getBusinessDate() (benefit-api/libs/utils/welfare.util.ts (branch: prod):51-55) คืนวันที่ปัจจุบันในรูปแบบ yyyy-MM-dd ถ้า argument เป็น null — หมายเหตุ: implementation ปัจจุบันเหมือน getCurrentDate() เปี๊ยบ (ทั้งสองใช้ new Date().toISOString().slice(0, 10)) แม้ชื่อ method บอกว่า "business date" — เป็น hint ว่าอาจเคยวางใจให้เป็น T-1 แล้วเปลี่ยนมาเป็น T ภายหลัง

  2. Query ตาราง HRMI ที่ฝั่ง connection hrmi ด้วย raw SQL string ที่ interpolate ${bizDate} ตรง ๆ — benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):512, benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):261, 283:

typescript
WHERE FORMAT(hd.ModifiedDate, 'yyyy-MM-dd') = '${bizDate}'
AND   hd.ApproveStatus = 'Y'

จุดนี้ไม่ใช่ SQL injection รุนแรง เพราะ bizDate มาจาก WelfareUtil.getBusinessDate() ที่ generate ภายใน แต่ ManualBatchController ส่ง body.bizDate ผ่านเข้ามาเหมือนกัน — BatchJobDto (benefit-api/src/dto/batchJob.dto.ts (branch: prod):6-7) ใช้ @IsDateString() เท่านั้น ไม่ใช่ strict regex ที่กัน shell metacharacter ทั้งหมด — ถ้า DTO validator มีช่องโหว่ (เช่นยอมรับ ISO 8601 variants ที่มี escape sequence) ก็เปิดช่อง injection ได้

  1. Cross-database join — SQL join ข้าม 2 database ภายในประโยคเดียวผ่าน UNION ALL ระหว่างตารางใน dbHRMI_Center และ dbHRMI_Center_NBC.dbo.* (benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):507-509) — เป็น pattern ที่ต้องมี linked server / cross-database permission ตั้งไว้ล่วงหน้าใน MSSQL ถ้า DBA ปิด permission นี้ job จะ fail ทุกวันโดยไม่มี error message ที่อ่านง่าย

  2. Check duplicateWelfareUtil.countWelfareHeaderByOriginalId() (benefit-api/libs/utils/welfare.util.ts (branch: prod):57-66) ตรวจว่าเคย import welfareOrigId นี้แล้วหรือยัง ส่งคืน object { countHd, aggOrigDetId } ที่ task service ใช้ตัดสินใจ:

    • countHd == 0 && aggOrigDetId == null → insert WelfareHeader + PaymentTransactionEntity ใหม่ใน transaction เดียว (benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):122-125)
    • countHd == 1 && !aggOrigDetId.includes(funeralDtId) → insert WelfareDetail + WelfareBankDetail เพิ่ม (กรณี HD มีอยู่แล้ว แต่มีรายการย่อยใหม่) — benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):126-136
  3. บันทึกผล batchTransaction — สำเร็จเรียก WelfareUtil.storeBatchTransaction() (benefit-api/libs/utils/welfare.util.ts (branch: prod):132-149) ที่ insert row ใหม่ด้วย status SUCCESS; ถ้า catch error เรียก storeBatchTransactionInCaseException() (benefit-api/libs/utils/welfare.util.ts (branch: prod):151-166) ที่ insert row ด้วย status FAILED พร้อมเก็บ errors ใน column varchar(1000) — ขนาด column เล็กทำให้ stack trace ยาว ๆ ถูก truncate

Bug ที่ซ่อนอยู่ใน import transaction

benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):122-125 ใช้ manager.save() ที่รับ entity ที่มี relation welfareDetail/welfareBankDetail (cascaded) แต่ manager.save(WelfareHeader, entity) จะ save relation เฉพาะตอนที่ relation ถูก load แล้วเท่านั้น การ cascade insert ใน TypeORM 0.3 ต้องเปิด cascade: true ที่ @OneToMany decorator ของ entity — UNVERIFIED ว่าเปิดไว้หรือไม่ (ต้องอ่าน entity เพิ่ม)

ส่วนฝั่ง inheritance (benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):113-116) ใช้ this.entityManagerMain.save() นอก manager.transaction() scope — เป็น bug ที่ทำให้การ insert WelfareHeader ไม่ได้อยู่ใน transaction เดียวกับ PaymentTransaction:

typescript
await this.entityManagerMain.transaction(async (manager) => {
  const entity = await this.entityManagerMain.save(WelfareHeader, ...);
  //                     ^^^^^^^^^^^^^^^^^^^^^^^ — ใช้ entity manager นอก transaction!
  await manager.save(PaymentTransactionEntity, ...);
});

ถ้า manager.save(PaymentTransactionEntity) fail — WelfareHeader ที่ insert ด้วย entityManagerMain (ไม่ใช่ manager) จะ commit อยู่ ทำให้เกิด orphan WelfareHeader โดยไม่มี PaymentTransaction คู่กัน — เป็น data inconsistency ที่ batch ถัดไปอาจไม่ตรวจพบเพราะ countWelfareHeaderByOriginalId จะเจอว่ามี HD อยู่แล้วแล้วข้ามไป


Job #2 — updateDataToHRMI (21:00 ทุกวัน)

benefit-api/src/crontab/cronTab.service.ts (branch: prod):37-50:

typescript
@Cron(CronExpression.EVERY_DAY_AT_9PM)
private async updateDataToHRMI() {
  await this.funeralTaskService.doUpdateFuneralForHRMI(null);
  await this.inheritanceTaskService.doUpdateInheritanceForHRMI(null);
}

CronExpression.EVERY_DAY_AT_9PM คือ '0 21 * * *' (5 fields) — ตรงกับเวลา 21:00 ทุกวัน

What job ทำ (จาก doUpdateFuneralForHRMI + doUpdateInheritanceForHRMI)

  1. คำนวณ bizDate — ใช้ WelfareUtil.getCurrentDate() (ไม่ใช่ getBusinessDate()) — benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):27 และ benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):28

  2. Query paymentTransaction ที่สำเร็จfindFuneralPaymentSuccess() (benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):518-536) และ findInheritancePaymentSuccess() (benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):267-285) — query จาก connection main (schema benefit) โดยใช้ raw SQL ที่ filter FORMAT(pt.updatedAt, 'yyyy-MM-dd') = '${bizDate}' อีกครั้ง

  3. Upsert ลง i3_Welfare_Result — สำหรับทุกแถวผล ตรวจ WelfareUtil.findWelfareResultByRefId() (benefit-api/libs/utils/welfare.util.ts (branch: prod):79-85) ที่ฝั่ง connection hrmi ถ้ามีอยู่ → update; ถ้าไม่มี → insert WelfareResult entity (benefit-api/src/entities/welfareResult.entity.ts (branch: prod), table name i3_Welfare_Result)

  4. Update WelfareHeader ฝั่ง main — หลัง upsert HRMI แล้ว กลับมา update WelfareHeader ฝั่ง main ด้วย payDate, isCancel, statusAction (benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):551-575) — เป็นการ mark ว่า row นี้ถูก sync ไป HRMI แล้ว

จุดสำคัญ — job #2 เขียนกลับเข้า HRMI database โดยตรง ผ่าน connection hrmi ไม่ผ่าน API ใด ๆ — ต่างจาก pattern ทั่วไปที่ integration ควรไปผ่าน service boundary ทำให้ HRMI schema เป็น shared write target ระหว่าง HRMI application เดิมกับ benefit-api


Cross-service impact

ทั้งสอง cron job ไม่ได้เรียก HTTP API ของ service อื่น — ไม่มี HttpModule/axios call ใน task service ทั้งคู่ การ sync ทำผ่าน database connection ตรง ทั้งหมด:

DirectionConnectionSchemaTableAction
HRMI → benefithrmi (read)dbHRMI_CenterhrFuneralHD, hrFuneralDT, hrInheritanceHD, hrInheritanceDT, vtrcAllProfile, emBank_reStructure ฯลฯSELECT (Job #1)
benefit → benefitmain (write)benefitwelfareHeader, welfareDetail, welfareBankDetail, paymentTransaction, paymentTransactionHistory, batchTransactionINSERT/UPDATE (Job #1 + #2)
benefit → HRMIhrmi (write)dbHRMI_Centeri3_Welfare_ResultUPSERT (Job #2)

ผลกระทบ cross-service:

  • ถ้า HRMI database ล่ม → Job #1 จะ fail ที่ขั้น SELECT (caught แล้วบันทึก batchTransaction status FAILED), Job #2 จะ fail ที่ขั้น UPSERT — ทั้งคู่ไม่มี side effect ฝั่ง service อื่นเพราะ error ถูก catch
  • ถ้า benefit database ล่ม → Job #1 และ #2 fail ทุกขั้นที่เขียน main — batchTransaction insert เองก็จะ fail ทำให้ไม่มีร่องรอย run history เลย
  • ถ้าเรียก ManualBatchController.importFuneral แบบ concurrent กับ cron ที่กำลังรัน → อาจเกิด duplicate insert เพราะไม่มี row lock ระหว่าง countWelfareHeaderByOriginalId กับ manager.save — race condition ที่จริง ๆ แล้วพบได้ยากเพราะ ops ไม่น่าจะ trigger manual ตอน 22:10 แต่ก็เป็นไปได้

Failure modes — สถานการณ์ที่ต้อง debug

กรณี 1: Cron ไม่รันเลย

สาเหตุที่เป็นไปได้ตามลำดับความน่าจะ:

  1. ScheduleModule.forRoot() ไม่ถูก import — เช็ค benefit-api/src/app.module.ts (branch: prod):67
  2. CronTabModule ไม่ถูก import — เช็ค benefit-api/src/app.module.ts (branch: prod):71
  3. NestJS process restart กลางคัน — @nestjs/schedule ไม่ persist schedule ระหว่าง restart
  4. Cron expression ผิด syntax — '10 22 * * *' และ EVERY_DAY_AT_9PM ตรวจแล้วถูกต้องตามมาตรฐาน 5 fields

กรณี 2: Cron รันแต่ data ไม่เข้า

อาการ — batchTransaction มี row status SUCCESS แต่ totalRecords = 0:

  1. FORMAT(hd.ModifiedDate, 'yyyy-MM-dd') ไม่ match bizDate — อาจเป็น timezone offset ที่ server (TZ ของ container) ไม่ตรงกับเวลาที่ HRMI เขียน ModifiedDate (TZ ของ DB server) — FORMAT ใช้ timezone ของ MSSQL server ไม่ใช่ของ Node.js
  2. hd.ApproveStatus = 'Y' — ถ้า HRMI ใช้ค่าอื่น (เช่น lowercase y) row จะถูก filter ออก
  3. hd.IsCancel = 0 — ถ้าเป็น NULL แทน 0 การเปรียบเทียบจะไม่ match (NULL semantics ใน SQL)

กรณี 3: Cron รันแล้วเกิด exception ที่ catch block

storeBatchTransactionInCaseException (benefit-api/libs/utils/welfare.util.ts (branch: prod):151-166) เก็บ error.message ใน column errors varchar(1000) — stack trace ยาว ๆ จะถูก truncate ทำให้ debug ยาก แนะนำอ่านจาก log ของ I3GatewayLogger แทน ที่เก็บ error.stack เต็ม ๆ (benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):146)

กรณี 4: Cron รันซ้อนกันเอง (ถ้า multi-replica)

อาการ — batchTransaction มีหลาย row ต่อวัน ๆ ละ job — ทุก row status SUCCESS แต่ data ใน welfareHeader อาจซ้ำ (ถ้าไม่มี unique constraint ที่ welfareOrigId) วิธีตรวจ — query SELECT bizDate, jobName, COUNT(*) FROM batchTransaction GROUP BY bizDate, jobName HAVING COUNT(*) > 1 ถ้าเจอ row ที่มี count > 1 แสดงว่า deployment มีหลาย replica จริง

กรณี 5: ระหว่างรัน long-running แล้ว deploy

ถ้า deploy ตอน 22:11 (หลัง cron เริ่ม 1 นาที) — pod ถูก kill ทำให้ manager.transaction() ที่ค้างอยู่ถูก rollback อัตโนมัติจาก DB side (good) แต่ batchTransaction ไม่ได้ถูกบันทึกว่า FAILED (bad) — ทำให้ run history ขาดช่วง ต้องดู log ของ pod เก่าแทน


Manual trigger — ManualBatchController

benefit-api/src/modules/batchJob/manualBatch.controller.ts (branch: prod):1-36 เปิด endpoint 4 ตัวให้ ops เรียก 4 method ข้างต้นด้วยมือ พร้อมระบุ bizDate เองใน request body — ใช้สำหรับ backfill ข้อมูลย้อนหลัง

Endpointกระทบ methodHTTP
POST /{BASE_PATH}api/v1/batch/importFuneraldoImportFuneral(body.bizDate)22:10 cron equivalent
POST /{BASE_PATH}api/v1/batch/importInheritancedoImportInheritance(body.bizDate)22:10 cron equivalent
POST /{BASE_PATH}api/v1/batch/updateFuneraldoUpdateFuneralForHRMI(body.bizDate)21:00 cron equivalent
POST /{BASE_PATH}api/v1/batch/updateInheritancedoUpdateInheritanceForHRMI(body.bizDate)21:00 cron equivalent

จุดวิกฤต — @Public() ทั้ง 4 endpoint

benefit-api/src/modules/batchJob/manualBatch.controller.ts (branch: prod):13, 19, 25, 31 — ทั้งสี่ endpoint ติด decorator @Public() ทำให้ ไม่ต้องมี JWT token ก็เรียกได้ — เป็น global guard bypass ที่ทำงานจริงเพราะ main.ts ตั้ง useGlobalGuards(new JwtAuthGuard(reflector)) แล้ว @Public() จะ mark metadata ที่ guard ใช้ reflector ตรวจแล้วข้าม

ผลกระทบ — ใครก็ตามที่ยิง POST /batch/importFuneral พร้อม body { "bizDate": "2020-01-01" } จะทำให้ cron รัน backfill 4 ปีย้อนหลังทันที — อาจทำให้ DB โหลดหนัก หรือ data corrupt ถ้า bizDate เป็นวันที่ HRMI schema มีโครงสร้างต่างจากปัจจุบัน

นี่คือ critical bug ที่ทำให้ batch endpoint เปิด public โดยไม่มี RBAC — ควรเพิ่ม JWT auth หรือ admin-only guard ทันที (ระบุไว้ใน 06 Scorecard SEC-BENEFIT-04 ของ V2)

ManualBatchService error handling ต่างจาก task service

benefit-api/src/modules/batchJob/manualBatch.service.ts (branch: prod):22-65 catch error ของ task method แล้ว return { message: 'failure', error: error.message } แทนที่จะ throw — ทำให้ controller return HTTP 200 แม้ job fail (silent failure ที่ frontend อาจไม่สังเกตเห็น) — pattern ที่ควรเปลี่ยนเป็น throw BadRequestException(error) เพื่อ return HTTP 4xx/5xx จริง ๆ

นอกจากนี้ inheritance task service ที่ throw BadRequestException(error) อยู่แล้ว (benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):75, 165) — จะถูก catch ที่ ManualBatchService แล้ว convert เป็น { message: 'failure' } ทำให้ error context หายไป


Cron library & schedule expression ที่ใช้

benefit-api/package.json (branch: prod):33"@nestjs/schedule": "^2.2.0" ซึ่ง dep ต่อกับ cron package v2.x ภายใน — cron package v2 ใช้ 5-field syntax (minute hour day-of-month month day-of-week) ที่เป็นมาตรฐาน POSIX

สองค่าที่ใช้ใน CronTabService:

ExpressionSourceMeaning
'10 22 * * *'literal stringminute=10, hour=22, every day → 22:10 ทุกวัน
CronExpression.EVERY_DAY_AT_9PMenum constant'0 21 * * *' → 21:00 ทุกวัน

ไม่มี timezone override — @Cron decorator รับ arg ที่ 2 เป็น CronOptions ที่มี timeZone: string แต่ไม่ได้ส่ง ทำให้ cron รันใน timezone ของ container โดย default — ถ้า container รันใน TZ=Asia/Bangkok (UTC+7) ก็จะรัน 22:10 ตามเวลาไทย แต่ถ้า container รันใน UTC จะรัน 22:10 UTC ซึ่งคือ 05:10 วันถัดไปของเวลาไทย — เป็น deployment-dependent behavior ที่ควรกำหนด timeZone ให้ชัดเจน


จุดที่ควรระวัง (summary)

  • ไม่มี distributed lock / leader election — ถ้า deploy มากกว่า 1 replica จริง cron จะรันซ้ำทุก instance ทำให้ data duplicate หรือ race condition ที่ DB
  • ไม่มี retry / dead-letter — ถ้า cron fail กลางทาง (เช่น network ไป HRMI ขาดตอน) ต้องรอ manual trigger จาก ops ผ่าน ManualBatchController เท่านั้น
  • ไม่มี timeout@nestjs/schedule ไม่กำกับ duration ของ job ถ้า HRMI query ค้าง 30 นาที cron จะรอ 30 นาทีจนเสร็จ และจะทับซ้อนกับ schedule รอบถัดไปถ้ามี
  • ไม่มี alerting — exception ที่ catch บันทึกเป็น row ใน batchTransaction กับ log เท่านั้น ไม่มี Prometheus metric, Sentry หรือ webhook แจ้ง ops
  • ManualBatchController เปิด @Public() ทั้ง 4 endpoint — ใครก็ trigger backfill ข้ามปีได้ ไม่ต้อง login (ดู SEC-BENEFIT-04)
  • Bug transaction scope ใน inheritance taskentityManagerMain.save() นอก manager.transaction() ทำให้ WelfareHeader insert อาจเป็น orphan (ดูรายละเอียดข้างต้น)
  • DTO BatchJobDto.bizDate ใช้ @IsDateString() เท่านั้น — ไม่ใช่ strict regex ที่กัน shell metacharacter ทั้งหมด ทำให้ raw SQL ที่ interpolate ${bizDate} มีพื้นที่เสี่ยงเล็กน้อย
  • console.log + this.logger.log ซ้อนกันทุกบรรทัด — duplicate logging ที่เปลือง stdout (ดู OBS-BENEFIT-01)
  • WelfareUtil.getBusinessDate() ไม่ได้คืน T-1 จริง — ปัจจุบันเหมือน getCurrentDate() ทั้งที่ชื่อฟังก์ชันบอก business date ทำให้น่าสงสัยว่ามี semantic drift หรือเปลี่ยนแปลง implementation ภายหลัง

Modernization target — สิ่งที่ควรจะเป็น

ปัจจุบันควรจะเป็น
@nestjs/schedule in-processBullMQ + Redis (distributed) หรือ k8s CronJob (separate workload)
Manual @Cron ทุก replicaDistributed lock (Redlock) หรือ sp_getapplock ของ MSSQL
try/catch + log เฉย ๆJob status table + retry policy + dead-letter queue + Prometheus metric
console.log + logger.log ซ้อนStructured logger (pino) เดียว พร้อม redact field ที่เป็น PII
@Public() manual triggerJWT guard + RBAC role ADMIN + IP allowlist ถ้า trigger จาก VPN เท่านั้น
Schedule ไม่ระบุ timeZone@Cron('...', { timeZone: 'Asia/Bangkok' }) เสมอ
entityManagerMain.save() นอก transactionsweep ทุก task service ให้ใช้ manager.save() ภายใน transaction() scope

เคล็ดลับตอนทำงานจริง

ถ้า cron ไม่รัน — เช็กตามลำดับ

  1. NestJS process รันอยู่ไหม — kubectl get pod -l app=benefit-api
  2. ScheduleModule.forRoot() import อยู่ใน AppModule.imports ไหม (benefit-api/src/app.module.ts (branch: prod):67)
  3. CronTabModule import อยู่ใน AppModule.imports ไหม (benefit-api/src/app.module.ts (branch: prod):71)
  4. timezone ของ container — kubectl exec ... -- date ถ้าไม่ใช่ Asia/Bangkok cron อาจรันผิดเวลาจากที่คาด
  5. batchTransaction table มี row ล่าสุดไหม — SELECT TOP 10 * FROM batchTransaction ORDER BY id DESC ถ้าไม่มี row ใหม่แสดงว่า cron ไม่ได้รันจริง

ถ้าเจอ orphan WelfareHeader (inheritance case)

อาการ — welfareHeader row ที่ไม่มี paymentTransaction คู่กัน มักเกิดจาก bug ที่ benefit-api/src/crontab/inheritance/inheritance.task.service.ts (branch: prod):113-116 ใช้ entityManagerMain นอก transaction วิธีแกะรอย — query SELECT wh.id FROM welfareHeader wh LEFT JOIN paymentTransaction pt ON pt.docId = wh.id AND pt.docType = 5 WHERE pt.id IS NULL ถ้าเจอ row แสดงว่า bug นี้เกิดขึ้นจริงใน production

ถ้าจะแก้ task service ต้องระวัง module duplication

FuneralTaskService และ InheritanceTaskService ถูก declare เป็น provider ใน 3 module — FuneralTaskModule, InheritanceTaskModule, CronTabModule, ManualBatchModule — แก้แล้ว build ผ่านก็ไม่แปลว่าทุก caller ได้ instance ตัวเดียวกัน ถ้าจะ refactor เชิญเปลี่ยนเป็น @Global() module หรือย้ายไป shared module เดียวก่อน


ไป บท 6 Scorecard + debt register