Zettelkasten

BullMQ worker handler에는 timeout을 직접 걸어야 한다

·수정 2026.05.12·수정 1

요약

  • BullMQ의 attempts, stalledInterval, maxStalledCount 옵션은 worker process가 죽었을 때만 작동한다. promise가 무한 await 상태(hang)인 경우엔 잡지 못한다.
  • worker handler 안의 외부 호출(API, DB, Redis Lua 등)에 timeout이 없으면 한 job이 영원히 active 상태로 남아 큐 전체가 마비될 수 있다.
  • handler 전체를 Promise.race([handler(), timeout])로 감싸거나, 모든 외부 호출에 timeout을 명시해야 한다.

본문

BullMQ의 기본 안전망이 작동 안 하는 케이스

BullMQ는 job 실패 시 retry, stalled 감지 등 안전장치가 있지만 다음 조건에서는 모두 무력화된다.

  • attempts: 3: handler의 promise가 resolve/reject 둘 중 하나로 끝나야 attempt 카운트가 올라간다. 무한 await 상태에서는 영원히 attempt 1에 머문다.
  • stalledInterval / maxStalledCount: worker process가 lock을 갱신 못 할 때 stalled로 판정한다. 그런데 promise가 hang인 상태여도 worker process 자체는 살아있으므로 lock 갱신은 background timer에서 계속 일어난다. 결과적으로 stalled로 옮겨가지 않는다.
  • 컨테이너 재배포: ECS 재시작/재배포 시 새 worker는 active list에 남아있는 job을 그대로 picks up한다. 같은 stale job을 계속 처리 시도 → 또 같은 곳에서 hang.

worker process는 살아있는데 처리 중인 job만 영구 hang인 경우, BullMQ 자체로는 절대 복구되지 않는다. 외부에서 active list에서 강제 제거하는 것만이 유일한 복구 수단.

원칙

worker handler 안의 모든 외부 호출에 timeout을 명시한다. 가장 안전한 방법은 handler 전체를 timeout으로 감싸는 것.

async processJob(job) {
  await Promise.race([
    this.doActualWork(job),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('job timeout')), 30000)
    )
  ]);
}

timeout에 걸리면 reject되므로 BullMQ가 정상적으로 attempt 카운트를 올리고 retry/failed 처리한다. 안전장치 다시 작동.

사례 (2026-05-12 매칭 서버 14시간 다운)

prod 매칭 서버의 defaultCallMatching 워커가 17:06:16에 한 job 처리 중 외부 호출에서 hang. 14시간 동안 같은 job이 active list에 남아있었고 뒤로 6,871개 job이 wait queue에 누적. 매칭 0건.

ECS 재배포 여러 번 해도 새 worker가 같은 stuck job을 다시 잡으면서 재발. 결국 Redis에서 active list의 stuck job을 강제 제거하니 즉시 복구됨.

복구 절차 (BullMQ active stuck)

증상:

  • LLEN bull:<queue>:wait가 비정상적으로 누적
  • LRANGE bull:<queue>:active 0 0에 동일 job ID가 오래 남아있음
  • 처리량 0건이지만 worker process는 살아있음

진단:

redis-cli LLEN bull:<queue>:active
redis-cli LRANGE bull:<queue>:active 0 0
redis-cli HGETALL bull:<queue>:<jobId>            # processedOn 시각 확인
redis-cli TTL bull:<queue>:<jobId>:lock           # lock 갱신되고 있는지

복구:

redis-cli DEL bull:<queue>:<jobId>
redis-cli DEL bull:<queue>:<jobId>:lock
redis-cli LREM bull:<queue>:active 0 <jobId>

이후 worker는 즉시 wait queue 처리 재개.

참고