..

Night Audit Bị Treo Và Cuộc Hành Trình Tìm Thủ Phạm

Phần 1: Câu Chuyện Mở Đầu

Hôm đó là 2 giờ sáng. Hệ thống vừa kick off tác vụ night audit — cái quy trình chạy mỗi đêm để chốt sổ, xuất báo cáo phòng, rồi gửi email cho quản lý khách sạn. Một công việc bình thường như việc bật máy pha cà phê mỗi buổi sáng.

Nhưng cà phê hôm đó không chịu ra.

Báo cáo không về. Email không gửi. Hệ thống ngồi im như pho tượng. Log thì chỉ ghi mỗi dòng: exportForNightAuditReport fromDate: ... rồi… không còn gì nữa.

Dev on-call nhìn màn hình, nhìn cốc nước, nhìn lại màn hình. Process vẫn sống nhưng không làm gì cả. Kiểu như con mèo ngồi trước bát cơm mà không ăn — nó không chết, nó chỉ đang… không hoạt động.

Đó là lúc ticket được tạo ra: “Night audit stuck. Room discrepancy report không xuất được. Không có lỗi. Không có gì cả."


Phần 2: Vấn Đề Và Bối Cảnh

Thủ phạm số 1: Các lời gọi database đứng thành hàng dọc

Night audit cần xuất hai loại báo cáo: Block Room ReportRoom Discrepancy Report. Mỗi báo cáo cần dữ liệu từ nhiều nguồn: lịch sử block phòng, log discrepancy, màu trạng thái phòng, thông tin khách sạn.

Code cũ làm theo kiểu… đứng chờ từng người một:

// Cách cũ: chờ xong cái này mới làm cái kia
const blockRoomData = await fetchBlockRoomHistory(...)     // chờ 2s
const metaData      = await getColorStatus()               // chờ 500ms
const propertyDetail = await getPropertyDetail(propertyId) // chờ 1s
const roomDiscrepancyData = await fetchRoomDiscrepancy(...)// chờ 2s
// Tổng: ~5.5 giây chỉ để lấy dữ liệu

Tưởng tượng bạn đang nấu phở: thay vì đun nước, thái thịt, và ngâm bánh phở cùng lúc — bạn đun nước xong, rồi mới thái thịt, xong rồi mới ngâm bánh. Với báo cáo có dữ liệu lớn, thời gian cộng dồn lại, dễ chạm timeout của Puppeteer hoặc HTTP client, rồi process “treo” mà không có exception nào để bắt.

Sau khi fix, bốn lời gọi đó chạy song song:

// Cách mới: tất cả cùng chạy một lúc
const [blockRoomData, roomDiscrepancyData, metaData, propertyDetail] = await Promise.all([
  fetchBlockRoomHistory(...),
  fetchRoomDiscrepancy(...),
  getColorStatus(),
  getPropertyDetail(propertyId)
])
// Tổng: ~2 giây (cái lâu nhất quyết định)

Thủ phạm số 2: N+1 Query — Kẻ giết hiệu năng kinh điển

Room Discrepancy Report cần hiển thị lý do của từng sự chênh lệch trạng thái phòng (ví dụ: “phòng HK báo Dirty nhưng FO báo Occupied”). Mỗi dòng log có thể có nhiều reasonId.

Code cũ xử lý thế này:

// Với MỖI dòng log, với MỖI reasonId:
for (let row of data) {
  for (let reasonId of row.extraData.reasonId) {
    const reason = await DB.ConfigDiscrepancyReason.findOne({
      where: { id: reasonId }
    })
    // xong rồi mới xử lý tiếp
  }
}

Nếu có 100 dòng log, mỗi dòng có 3 reason — hệ thống thực hiện 300 lần query database. Đây là N+1 query problem — một trong những bẫy kinh điển nhất của ORM. Giống như đi siêu thị mua 300 món mà mỗi lần chỉ mua 1 thứ, đi về rồi lại ra, đi về rồi lại ra.

Before: 300 dòng log × 3 reasonId = 900 queries database
After:  1 query lấy tất cả reasons → tra cứu từ Map O(1)

Cách fix: thu thập hết tất cả reasonId unique, query 1 lần duy nhất, build một Map trong memory, rồi tra cứu từ đó:

// Bước 1: gom hết reasonId từ tất cả các dòng
const allReasonIds = rows.flatMap(r => r.extraDataObj?.reasonId || [])
const uniqueIds = [...new Set(allReasonIds)]

// Bước 2: 1 query duy nhất
const reasons = await DB.ConfigDiscrepancyReason.findAll({
  where: { id: uniqueIds }
})

// Bước 3: build map O(1)
const reasonMap = Object.fromEntries(reasons.map(r => [r.id, r]))

// Bước 4: dùng trong loop — không có DB call nào nữa
for (const row of rows) {
  for (const reasonId of row.extraDataObj.reasonId) {
    const reason = reasonMap[reasonId] // instant lookup
  }
}

Thủ phạm số 3: Thông tin khách trong báo cáo bị trống

Báo cáo discrepancy hiển thị tên khách, VIP level, quốc tịch — nhưng khi chạy cho ngày hôm qua (ngày business date đã qua), tất cả những cột đó đều trống hoặc sai.

Nguyên nhân: hàm generateReservationWhereStatement dùng new Date() — tức là thời điểm hiện tại — để lọc reservation đang active. Nhưng khi bạn xuất báo cáo cho ngày 3/3, hệ thống lại lấy reservation của ngày hôm nay (5/3), nên không khớp với log nào cả.

// Cách cũ: luôn dùng ngày hôm nay
const startOfDay = new Date()
startOfDay.setUTCHours(0, 0, 0, 0)

// Cách mới: dùng ngày của báo cáo nếu có
const generateReservationWhereStatement = (model, bizDate = null) => {
  const baseDate = bizDate ? new Date(bizDate) : new Date()
  const startOfDay = new Date(baseDate)
  startOfDay.setUTCHours(0, 0, 0, 0)
  // ...
}

Đơn giản, nhưng tác động rất lớn: thông tin khách giờ đúng với ngày của báo cáo.

Thủ phạm số 4: item.resolvedAt thay vì row.resolvedAt

Một bug nhỏ nhưng tinh quái. Trong hàm parse dữ liệu block room:

// Bug: gán lên object gốc (item), không phải output (row)
item.resolvedAt = ''

// Fix: gán đúng vào output object
row.resolvedAt = ''

Về mặt runtime thì không crash — nhưng cột resolvedAt trong báo cáo luôn bị trống ở những dòng mà phòng chưa được giải phóng. Ai đọc báo cáo mà không biết thì cứ nghĩ dữ liệu đang miss.

Thủ phạm số 5: Lỗi nuốt chửng, không re-throw

Khi exportPdf hoặc exportExcel thất bại, code cũ trả về chuỗi rỗng '':

// Cách cũ: thất bại âm thầm
} catch (err) {
  logger.error('ERREXPRTPDF: ', err)
  return ''  // caller không biết có lỗi
}

Đây là kiểu “nuốt lỗi” — caller nhận '' tưởng là thành công, tiếp tục xử lý với URL rỗng, rồi gửi message queue với link báo cáo là ''. Hệ thống downstream nhận được link trống, không biết phải làm gì, và toàn bộ flow night audit coi như… vô nghĩa.

Sau khi fix: lỗi được throw ra ngoài, và trước khi gửi message queue, hệ thống validate rằng tất cả URL và key đều hợp lệ:

const invalidExport = exports.find((e) => !e || !e.url || !e.key)
if (invalidExport) {
  throw new Error(`Report export failed: missing url or key`)
}

Nếu có gì sai, crash to và rõ ràng — còn hơn âm thầm gửi link rỗng.


Phần 3: Giải Pháp

Toàn bộ fix gói gọn trong ba hướng:

1. Parallelism — chạy song song những gì độc lập với nhau

Promise.all() được dùng để fetch 4 nguồn dữ liệu cùng lúc. Thay vì tổng cộng ~~5-6 giây tuần tự, giờ chỉ mất thời gian của request chậm nhất (~~2 giây). Puppeteer không còn bị timeout trước khi nhận được dữ liệu.

  • Pros: giảm latency đáng kể, tránh timeout ✅
  • Cons: nếu một trong bốn request fail, Promise.all sẽ reject ngay — cần đảm bảo error handling tốt ở tầng trên

2. Batch query — thay N queries bằng 1

Thay vì findOne trong loop, dùng findAll với IN (id1, id2, ...) rồi build Map. Đây là cách xử lý chuẩn cho N+1 problem.

  • Pros: giảm từ hàng trăm queries xuống còn 1, database không bị stress
  • Cons: nếu số lượng uniqueIds rất lớn (vài nghìn), câu IN cũng có thể chậm — cần thêm index trên cột id (thường đã có sẵn là PK)

3. Strict error propagation — lỗi phải được nhìn thấy

Không còn return '' trong catch block. Tất cả lỗi được throw, và caller validate output trước khi tiếp tục. Silent failures là nguồn gốc của nhiều bug khó debug nhất.

  • Pros: fail fast, dễ alert, dễ tìm nguyên nhân
  • Cons: cần đảm bảo tất cả caller đều có try-catch phù hợp, không để lỗi uncaught đến tay end-user

Phần 4: Kiến Thức Đại Học Ứng Dụng

Môn: Cơ Sở Dữ Liệu (Database Systems)

Bài học N+1 query là minh họa sống động nhất cho khái niệm query optimization mà môn CSDL năm 2-3 đại học hay dạy.

Hồi học, thầy hay nói: “Hãy tối thiểu số lần round-trip giữa application và database." Lúc đó nghe xong gật đầu, về nhà code for-loop với findOne, mỗi vòng lặp query một lần, submit bài được điểm đủ qua.

Đến lúc đi làm production với 10.000 dòng log, mới hiểu tại sao thầy nhấn mạnh điều đó. Mỗi round-trip database có overhead: network latency, connection pool, query parsing. Nhân với N là con số rất thật.

WHERE id IN (1, 2, 3, ..., 300) — một câu lệnh SQL duy nhất — làm được việc của 300 câu riêng lẻ. Index trên id giúp database tìm ngay, không cần scan toàn bộ bảng.

Môn: Hệ Điều Hành (Operating Systems) / Lập Trình Song Song

Promise.all() về bản chất là concurrent I/O — Node.js event loop dispatch nhiều async operation cùng lúc mà không block thread.

Môn OS dạy khái niệm này qua các bài về process/thread synchronization, I/O multiplexing (select, epoll). Node.js xây dựng toàn bộ model của nó trên libuv, hiện thực hóa đúng những gì giáo trình OS lý thuyết mô tả.

Hồi đó tôi tưởng học non-blocking I/O chỉ để thi. Hóa ra đây là nền tảng của cả một runtime.


Phần 5: Bài Học Rút Ra

  • Silent failures nguy hiểm hơn crashes. Trả về '' thay vì throw error khiến hệ thống tiếp tục chạy với dữ liệu sai, rồi bạn mất nửa ngày để tìm xem ai đã âm thầm nuốt cái lỗi đó.
  • N+1 query không phải lỗi compile-time — nó là lỗi runtime nhân theo dữ liệu. Code chạy đúng trên 10 dòng test, rồi chết trên 1.000 dòng production. Hãy nhìn vào loop và hỏi: “Có DB call nào trong đây không?”
  • Những gì độc lập với nhau nên chạy song song. Nếu A không cần kết quả của B, đừng để B ngồi chờ A xong. Promise.all là công cụ đơn giản nhất để làm điều này trong JavaScript async.
  • Bug item.resolvedAt thay vì row.resolvedAt nhắc rằng: mutation trên object gốc trong một pipeline transform là nguồn gốc của bug khó thấy. Immutable output pattern — build một object mới thay vì modify object đầu vào — giúp tránh loại này.
  • Ngày tháng trong báo cáo lịch sử phải dùng ngày của báo cáo, không phải new Date(). Tưởng hiển nhiên, nhưng rất dễ quên khi copy-paste function từ use-case real-time sang use-case historical.