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)
แนวทางแก้ (ไม่ได้แก้ในบทนี้):
| Pattern | Complexity | Note |
|---|---|---|
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 — ภาพรวม
| # | Job | Trigger | Schedule expression | Source method |
|---|---|---|---|---|
| 1 | importDataFromHRMI | 22:10 ทุกวัน (เวลา server) | '10 22 * * *' (raw string) | benefit-api/src/crontab/cronTab.service.ts (branch: prod):22-35 |
| 2 | updateDataToHRMI | 21: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:
@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ใช้cronpackage ที่เป็น 5 fields
What job ทำ (จาก doImportFuneral + doImportInheritance)
คำนวณ
bizDate—WelfareUtil.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 ภายหลัง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:
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 ได้
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 ที่อ่านง่ายCheck duplicate —
WelfareUtil.countWelfareHeaderByOriginalId()(benefit-api/libs/utils/welfare.util.ts (branch: prod):57-66) ตรวจว่าเคย importwelfareOrigIdนี้แล้วหรือยัง ส่งคืน object{ countHd, aggOrigDetId }ที่ task service ใช้ตัดสินใจ:countHd == 0 && aggOrigDetId == null→ insertWelfareHeader+PaymentTransactionEntityใหม่ใน transaction เดียว (benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):122-125)countHd == 1 && !aggOrigDetId.includes(funeralDtId)→ insertWelfareDetail+WelfareBankDetailเพิ่ม (กรณี HD มีอยู่แล้ว แต่มีรายการย่อยใหม่) —benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):126-136
บันทึกผล batchTransaction — สำเร็จเรียก
WelfareUtil.storeBatchTransaction()(benefit-api/libs/utils/welfare.util.ts (branch: prod):132-149) ที่ insert row ใหม่ด้วย statusSUCCESS; ถ้า catch error เรียกstoreBatchTransactionInCaseException()(benefit-api/libs/utils/welfare.util.ts (branch: prod):151-166) ที่ insert row ด้วย statusFAILEDพร้อมเก็บ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:
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:
@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)
คำนวณ
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):28Query 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 จาก connectionmain(schemabenefit) โดยใช้ raw SQL ที่ filterFORMAT(pt.updatedAt, 'yyyy-MM-dd') = '${bizDate}'อีกครั้งUpsert ลง
i3_Welfare_Result— สำหรับทุกแถวผล ตรวจWelfareUtil.findWelfareResultByRefId()(benefit-api/libs/utils/welfare.util.ts (branch: prod):79-85) ที่ฝั่ง connectionhrmiถ้ามีอยู่ → update; ถ้าไม่มี → insertWelfareResultentity (benefit-api/src/entities/welfareResult.entity.ts (branch: prod), table namei3_Welfare_Result)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 ตรง ทั้งหมด:
| Direction | Connection | Schema | Table | Action |
|---|---|---|---|---|
| HRMI → benefit | hrmi (read) | dbHRMI_Center | hrFuneralHD, hrFuneralDT, hrInheritanceHD, hrInheritanceDT, vtrcAllProfile, emBank_reStructure ฯลฯ | SELECT (Job #1) |
| benefit → benefit | main (write) | benefit | welfareHeader, welfareDetail, welfareBankDetail, paymentTransaction, paymentTransactionHistory, batchTransaction | INSERT/UPDATE (Job #1 + #2) |
| benefit → HRMI | hrmi (write) | dbHRMI_Center | i3_Welfare_Result | UPSERT (Job #2) |
ผลกระทบ cross-service:
- ถ้า HRMI database ล่ม → Job #1 จะ fail ที่ขั้น SELECT (caught แล้วบันทึก batchTransaction status FAILED), Job #2 จะ fail ที่ขั้น UPSERT — ทั้งคู่ไม่มี side effect ฝั่ง service อื่นเพราะ error ถูก catch
- ถ้า
benefitdatabase ล่ม → 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 ไม่รันเลย
สาเหตุที่เป็นไปได้ตามลำดับความน่าจะ:
ScheduleModule.forRoot()ไม่ถูก import — เช็คbenefit-api/src/app.module.ts (branch: prod):67CronTabModuleไม่ถูก import — เช็คbenefit-api/src/app.module.ts (branch: prod):71- NestJS process restart กลางคัน —
@nestjs/scheduleไม่ persist schedule ระหว่าง restart - Cron expression ผิด syntax —
'10 22 * * *'และEVERY_DAY_AT_9PMตรวจแล้วถูกต้องตามมาตรฐาน 5 fields
กรณี 2: Cron รันแต่ data ไม่เข้า
อาการ — batchTransaction มี row status SUCCESS แต่ totalRecords = 0:
FORMAT(hd.ModifiedDate, 'yyyy-MM-dd')ไม่ matchbizDate— อาจเป็น timezone offset ที่ server (TZ ของ container) ไม่ตรงกับเวลาที่ HRMI เขียนModifiedDate(TZ ของ DB server) —FORMATใช้ timezone ของ MSSQL server ไม่ใช่ของ Node.jshd.ApproveStatus = 'Y'— ถ้า HRMI ใช้ค่าอื่น (เช่น lowercasey) row จะถูก filter ออก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 | กระทบ method | HTTP |
|---|---|---|
POST /{BASE_PATH}api/v1/batch/importFuneral | doImportFuneral(body.bizDate) | 22:10 cron equivalent |
POST /{BASE_PATH}api/v1/batch/importInheritance | doImportInheritance(body.bizDate) | 22:10 cron equivalent |
POST /{BASE_PATH}api/v1/batch/updateFuneral | doUpdateFuneralForHRMI(body.bizDate) | 21:00 cron equivalent |
POST /{BASE_PATH}api/v1/batch/updateInheritance | doUpdateInheritanceForHRMI(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:
| Expression | Source | Meaning |
|---|---|---|
'10 22 * * *' | literal string | minute=10, hour=22, every day → 22:10 ทุกวัน |
CronExpression.EVERY_DAY_AT_9PM | enum 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 task —
entityManagerMain.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-process | BullMQ + Redis (distributed) หรือ k8s CronJob (separate workload) |
Manual @Cron ทุก replica | Distributed 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 trigger | JWT guard + RBAC role ADMIN + IP allowlist ถ้า trigger จาก VPN เท่านั้น |
| Schedule ไม่ระบุ timeZone | @Cron('...', { timeZone: 'Asia/Bangkok' }) เสมอ |
entityManagerMain.save() นอก transaction | sweep ทุก task service ให้ใช้ manager.save() ภายใน transaction() scope |
เคล็ดลับตอนทำงานจริง
ถ้า cron ไม่รัน — เช็กตามลำดับ
- NestJS process รันอยู่ไหม —
kubectl get pod -l app=benefit-api ScheduleModule.forRoot()import อยู่ในAppModule.importsไหม (benefit-api/src/app.module.ts (branch: prod):67)CronTabModuleimport อยู่ในAppModule.importsไหม (benefit-api/src/app.module.ts (branch: prod):71)- timezone ของ container —
kubectl exec ... -- dateถ้าไม่ใช่ Asia/Bangkok cron อาจรันผิดเวลาจากที่คาด batchTransactiontable มี 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 เดียวก่อน