Preview Bản nháp chờ review — title, nội dung, link có thể sửa trước khi bỏ nhãn.

User đóng tab. Gateway trả 504. Dashboard đỏ. Bên trong: handler vẫn await db.query 15s, connection pool đầy việc không ai chờ kết quả.

Timeout budget trả lời: còn bao lâu cho cả chuỗi? Cancellation trả lời: ai bảo việc dang dở phải dừng — và nó có nghe không?

Demo (budget hết: chỉ báo lỗi vs abort thật): cancellation · orphan vs abort.

Timeout ≠ cancel

// “Hết giờ” ở tầng ngoài — bên trong có thể vẫn chạy
const result = await Promise.race([
  doWork(),                          // không nhận signal
  sleep(2000).then(() => { throw new Error('timeout') }),
])
// doWork() không bị dừng — việc mồ côi

Race chỉ chọn promise nào win cho caller. Nó không tự abort I/O, không trả connection, không bảo query ngừng scan.

Cancel đúng nghĩa:

  1. tín hiệu (AbortSignal, context cancel…).
  2. Tín hiệu truyền xuống mọi I/O / sub-call.
  3. Code / driver tôn trọng — cooperative: check giữa các await hoặc API native abort.

AbortSignal / context — một cha cho cả request

// JS — một controller cho handler
const ac = new AbortController()
const timer = setTimeout(() => ac.abort('budget'), budgetMs)

try {
  await fetch(url, { signal: ac.signal })
  await db.query(sql, { signal: ac.signal }) // driver phải support
} finally {
  clearTimeout(timer)
}

// Gộp với client disconnect (nếu runtime cho signal):
// AbortSignal.any([ac.signal, req.signal])
// Go
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, sql)

Ý: một deadline/cancel gốc, không mỗi hop tự setTimeout riêng rồi quên abort con. Khớp budget xuyên tầng.

Việc mồ côi — vì sao đắt

  • Ôm DB pool / HTTP client / file descriptor.
  • CPU vẫn parse/result dù user đã đi.
  • Retry phía trên chồng thêm request trong khi bản cũ chưa chết → storm im lặng.
  • Metric 5xx tăng nhưng root là “không cancel”, không phải dependency chậm đơn thuần.

Nối in-flight inventory: orphan = SKU không ai nhận hàng mà vẫn giữ vốn.

Cooperative — cancel không phải preemption

Hầu hết cancel là cooperative (xem cooperative vs preemptive):

  • Tight loop CPU không await / không check signal → không dừng giữa chừng.
  • fs.readFileSync / crypto sync → không abort được cho đến khi xong (kẹt cả event loop).
  • Library không nhận signal → bạn chỉ “hết giờ với caller”, không với tài nguyên dưới.

Retry + cancel

// Sai: timeout lần 1, fire lần 2, lần 1 vẫn chạy
await attempt() // treo
// ... 2s ... throw
await attempt() // thêm 1 query

// Đúng: cùng budget; lần trước abort xong (hoặc chắc chắn) mới lần sau
// + idempotency nếu side-effect

Hết budget → đừng retry (retry cái gì). Còn budget → abort attempt cũ (nếu có) rồi mới attempt mới.

Worker / queue

  • Shutdown / deploy: cancel in-flight job, đừng để process kill nửa chừng không idempotent.
  • Lease/visibility hết: job có thể redelivery — handler phải chịu lần hai (lease, idempotency).
  • Cancel giữa job: state “partial” cần an toàn (compensate hoặc checkpoint).

Checklist

  1. Timeout chỉ ở ngoài hay có signal xuống I/O?
  2. Driver/HTTP client có abort API không?
  3. Client disconnect có nối vào cùng signal không?
  4. Metric aborted / canceled tách 5xx?
  5. Pool active có giảm khi client cút hàng loạt không?

Đọc tiếp

Một câu để nhớ

Hết giờ mà không cancel = vẫn trả CPU/pool cho việc không ai chờ.
Budget nói “khi nào”; cancel là “dừng đi”.