✅ Fix 머지 완료

lead-enrich 429 폭주 → sequence-email stuck — 원인 + Fix

2026-06-05 · alpha #8198 · beta #8200 머지 완료

📅 2026-06-05 07:21 KST📦 5-Layer Defense (헥사고날)🏷 5 파일 / +144 / -2

한 줄 원인

발신계정 18개 분산은 정상. 진짜 stuck 원인은 lead-on-demand-enrich worker 가 Hunter/Findymail 결제 한도 (429) 에 부딪혀 무한 retry 폭주 → 같은 Node.js process 의 sequence-email-loader 가 event loop 차례 못 얻어 overdue 30k+ 잡을 BullMQ 에 enqueue 못 함. catch 블록이 모든 에러를 enrichment_failed 로 매핑 → auto-trigger 가 다시 enqueue → 무한 루프.

1. 사건 타임라인

시간 (KST)이벤트
04:11beta CD 배포 healthcheck 240s 타임아웃 — Redis OOM 첫 의심
04:28Redis maxmemory 4G → 8G + swap=0 PR 머지 (#8175/#8176)
05:13AI 캠페인 6/2 사용자 pause+resume → 18계정 분산 적용
05:35글로벌 식품 브랜드사 pause+resume → 18계정 균등 발송 시작 (분당 52건)
05:40~발송 급감 (1-3건/분) — 진짜 원인 추적 시작
05:55Hunter 429 폭주 + lead-enrich 86k 적체 발견
06:305-Layer Defense 코드 작성
07:21fix(lead-enrich) 양쪽 머지 (alpha #8198, beta #8200)

2. 진짜 원인 — 무한 retry 사슬

┌─────────────────────────────────────────────┐ │ Findymail 결제 한도 초과 (월 cycle) │ │ ↓ 429 too_many_requests │ └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ run-fast-enrich.ts:122-130 catch 블록 │ │ → 429 분기 없이 모두 enrichment_failed │ │ → reason='internal_error' │ └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ worker → lead_enrichment_state.status = │ │ 'enrichment_failed' 저장 │ │ → BullMQ attempts:3 자동 재시도 │ └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ auto-enrich-trigger.ts:58 WHERE 절 │ │ status IN ('pending', 'enrichment_failed')│ │ → 같은 lead 자동 재enqueue (무한 루프) │ └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ concurrency 25 × pLimit(25) × 86k 큐 │ │ → Hunter/Findymail API 호출 폭주 │ │ → Node.js event loop ~89s 동결 │ └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ sequence-email-loader (30s tick) │ │ → DB scan & enqueue 차례 못 얻음 │ │ → overdue 30,339건 BullMQ 에 안 들어감 │ └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ sequence-email worker active=0 │ │ → 발송 stuck (분당 1-3건) │ └─────────────────────────────────────────────┘

3. 분산 자체는 정상 (참고)

캠페인enrollment분배
AI 캠페인 6/233,13218계정 균등 (847-953/계정, CV 3%)
글로벌 식품 브랜드사29,90318계정 균등 (1,265-1,424/계정, CV 3%)
chat 보존 (이미 sent step1)22,171thread 일관성 위해 sticky 유지
발신계정 분산은 SHA256 sticky binding 으로 완벽 동작. 문제는 BullMQ 처리율 — lead-enrich 가 점유한 event loop.

4. Fix — 5-Layer Defense (헥사고날)

Layer파일책임
1. Domainservices/lead-enrichment-fast/errors.ts (신규)ProviderQuotaExhaustedError + isQuotaErrorMessage() + isTransient()
2. Adapterfindymail.service.tsin-service retry 소진 후 429 → throw
3. Usecaserun-fast-enrich.tscatch + re-throw (worker 가 UnrecoverableError 변환)
4. Workerlead-on-demand-enrich.worker.tspermanent → UnrecoverableError, transient → BullMQ exp retry
5. DB status(같은 worker)status='unreachable' 저장 → auto-trigger WHERE 절 제외

핵심 메커니즘

  • UnrecoverableError — BullMQ attempts:3 자동 retry 차단 (workspace 별 전체 86k 폭주 중단)
  • status='unreachable''enrichment_failed' — auto-enrich-trigger 의 WHERE IN ('pending', 'enrichment_failed') 에 안 들어가 무한 재enqueue 루프 차단
  • 1일 negative cache 만료 후 다음날 자연 회복 (Findymail 결제 cycle 갱신 가정)
  • isTransient (Retry-After < 10분) 분기 — 일시 429 는 BullMQ exp retry 유지 (영구 vs 일시 구분)

5. 시나리오 검증 (7 cases all pass)

#시나리오결과
S1Findymail 영구 결제 한도✅ UnrecoverableError + status=unreachable
S2일시 429 (Retry-After < 10분)✅ isTransient → BullMQ exp retry
S3다중 provider (Hunter/Findymail/MV)✅ provider 별 lift
S4같은 워크스페이스 burst (86k)✅ status=unreachable → 재enqueue 안 됨
S5결제 cycle 갱신✅ negative cache 1d 만료 → 자연 회복
S6Worker crash 중간✅ jobId dedupe + stalled 감지
S7코드 배포 중 (기존 delayed 잡)✅ 새 catch 적용 — migration safe

6. 로그 최적 (Pino structured)

1. [Findymail] Quota exhausted after in-service retries — lifting to domain error
   { endpoint, maxRetries }

2. [FastEnrich] Provider quota exhausted — re-throwing for worker UnrecoverableError
   { jobId, leadId, provider, retryAfterMs, message }

3. [LeadEnrichWorker] Provider quota exhausted — permanent (blocking retry)
   { leadId, workspaceId, provider, retryAfterMs, isTransient, durationMs, jobId }
  • 3단계 로그 → adapter → usecase → worker 호출 chain 추적
  • structured field: provider, retryAfterMs, isTransient, durationMs
  • level: warn (의도된 차단, 시스템 정상)
  • prefix: 기존 관행 ([Findymail] / [FastEnrich] / [LeadEnrichWorker]) 유지

7. 코드 작성 history

파일작성자최근 변경
run-fast-enrich.tsGyudong Kim2026-05-19 (a56bd6c03)
lead-on-demand-enrich.worker.tsGyudong Kim2026-06-05 (1b4c03022) — lockDuration 30s→5m fix
auto-enrich-trigger.tsGyudong Kim2026-05-19 (a56bd6c03)
findymail.service.tsGyudong Kim이전 작성
lead-enrich 모듈 전체는 Gyudong Kim 작성. 오늘 (2026-06-05) lockDuration 5분 상향 fix 도 같은 사람 — 같은 incident 의 lock-만료 측면을 fix 했지만 근본 retry 폭주는 미해결. 이번 PR 이 그 나머지 (5-Layer Defense).

8. 후속 작업 (P1~P3)

우선순위작업설명
P1Redis circuit breakerworkspace × provider 5분 open — 첫 lead 만 429, 나머지 즉시 skip
P1Hunter / MV adapter 의 429 throw현재는 Findymail 만 throw
P1worker 컨테이너 분리lead-enrich 와 sequence-email Node.js process 격리
P2Domain Event (Slack alert + metric)quota_exhausted Workspace 자동 알림
P2Token bucket rate limiterprovider 호출 전 선제 차단
P3Workspace 월 USD ceiling 재활성화현재 의도적 disabled ("free feature")