Một message/job luôn fail: payload corrupt, schema đổi, bug logic, id không tồn tại mà code throw. Queue at-least-once + retry vô hạn → worker đốt CPU quanh message đó; hoặc (FIFO chặt) chặn cả lane phía sau.

Đó là poison message. Thuốc giải vận hành: giới hạn attempt, tách ra dead-letter queue (DLQ) / quarantine, alert, rồi quyết định fix & replay — không phải “retry thêm cho vui”.

Demo: Queue + poison → DLQ sau N attempts. Cùng họ với BullMQ failed / delayed.

Vòng đời lành mạnh

waiting → active → completed
                ↘ failed → (backoff) delayed → waiting  … × max
                ↘ DLQ / dead  (hết attempt hoặc lỗi “không retry”)
  • Transient (timeout, 503): retry + backoff/jitter.
  • Permanent (validation, 404 business, poison): đừng đốt hết max attempts nếu nhận ra sớm — fail to DLQ ngay.

Phân loại lỗi

function routeError(err) {
  if (err.code === "VALIDATION" || err.code === "NOT_FOUND") {
    return "dead"; // không retry
  }
  if (err.code === "TIMEOUT" || err.status === 503) {
    return "retry";
  }
  return "retry"; // default cẩn thận; unknown hay retry có budget
}

“Retry mọi Exception” biến bug deploy thành storm trên cùng một bad payload.

DLQ làm gì (và không làm gì)

  • Làm: giữ payload + metadata (attempts, last error, stack rút gọn, timestamp) để người xem; metric dlq_depth; alert khi tăng.
  • Không: coi DLQ là “xong việc”. Message trong DLQ = business có thể đang kẹt (đơn chưa xử lý).
  • Replay: sau khi fix code/data, đẩy lại main queue có chủ đích — kèm idempotency vì có thể đã side-effect dở.

Sketch consumer

async function handle(job) {
  try {
    await process(job.payload);
    await job.complete();
  } catch (e) {
    if (routeError(e) === "dead" || job.attempts >= job.maxAttempts) {
      await job.moveToDlq({
        error: String(e),
        attempts: job.attempts,
        payload: job.payload,
      });
      alertIfNeeded();
      return;
    }
    await job.retry({ delay: backoff(job.attempts) });
  }
}

FIFO & head-of-line

Queue yêu cầu thứ tự chặt: poison ở đầu có thể block consumer group. Chiến lược: timeout attempt ngắn + nhanh vào DLQ; hoặc lane “skip poison” có audit — trade-off ordering vs liveness. Đây là lý do nhiều hệ chọn per-key ordering thay vì global FIFO.

Quan sát

  • job_failed_total theo error_code
  • dlq_enqueued / depth / age of oldest
  • Tỷ lệ fail lặp cùng payload_hash → poison thật

Một câu để nhớ

Retry là cho lỗi tạm. DLQ là cho lỗi “không tự hết”. Không có max attempts + nơi chứa message độc, queue của bạn chỉ là vòng lặp đốt tiền quanh một payload hỏng.