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:
- Có tín hiệu (AbortSignal, context cancel…).
- Tín hiệu truyền xuống mọi I/O / sub-call.
- 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
- Timeout chỉ ở ngoài hay có signal xuống I/O?
- Driver/HTTP client có abort API không?
- Client disconnect có nối vào cùng signal không?
- Metric
aborted/canceledtách 5xx? - Pool active có giảm khi client cút hàng loạt không?
Đọc tiếp
- Timeout budget — hạn tổng.
- Event loop — sync không cancel được giữa chừng.
- Retry + jitter — nhịp; bài này + retry-what-when — khi nào bấm.
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”.