deferred 재활성 경로 없음으로 한 번 밀린 execution이 영구 정지. delayed ZSET의 57%(12.9만)가 7일 초과 미래, promote 지연분은 0건(promoter 무죄).
| 지표 | 글로벌 | 전체(all) | 해석 |
|---|---|---|---|
| sent_1h / sent_1d | 0 / 0 | 2 / 693 | 사실상 정지 (정상이면 하루 수만 건) |
| BullMQ delayed | 225,773 | 225,782 | 전 워크스페이스 적체의 거의 전부가 글로벌 |
| BullMQ wait / active | 0 / 0 | 0 / 0 | 워커가 받을 일감 없음 |
| pending execution | 323,861 | 960,413 | 대기 중인 발송 |
| overdue (기한초과) | 801 | 574,570 | 글로벌 801은 paused 누수분 |
| delivered / sent | 17,830 / 275,754 | 11.7만 / 87.7만 | delivery 추적 6%만 (별도 결함) |
| promote 예정 | 잡 수 | 판정 |
|---|---|---|
| 이미 지남 (score < now) | 0 | promoter 정상 — 지연분 없음 |
| ~5분 / 5분~1시간 | 10 / 107 | 정상 임박 |
| 1시간~1일 | 7,772 | — |
| 1일~7일 | 89,189 | 밀림 시작 |
| 7일 초과 (최대 75일) | 128,690 | stagger 폭주 — 핵심 |
75일 = 6,487,500,000ms ÷ 10,000ms = idx 648,750 — 단일 계정에 누적된 64.8만 번째 잡. stagger 간격(10초)에 상한이 없어 산술적으로 도출됨.
| status | 수 | 주요 시퀀스 (active enrollment) |
|---|---|---|
| active | 9 | 뷰티글로벌유통사DB 33,167 · 식품브랜드 28,487 · 뷰티AI제안 27,918 · 브랜드사6.1 19,381 · 뷰티AI제안2 19,297 · 브랜드→린다제안 10,889 · 유아생활용품 10,502 |
| completed | 8 | AI캠페인6/2 30,650 · 뷰티RINDA파트너십 8,261 · GTM글로벌바이어 4,456 … |
| paused | 1 | 뷰티유통사신규영업 — pending 797 누수 보유 |
| draft | 1 | 새 캠페인 만들기 |
| status | 수 | active step 분포 | 수 |
|---|---|---|---|
| active | 153,585 | STEP0 (1통도 미발송) | 76,259 (50%) |
| completed | 53,875 | STEP1 | 1,801 |
| bounced | 5,284 | STEP2 | 14,739 |
| stopped | 1,502 | STEP3 | 37,316 |
| unsubscribed | 444 | STEP4 | 23,470 |
active의 절반(76,259)이 STEP0 — 신규 enrollment가 첫 발송조차 못 받고 적체.
| 큐 | delayed | wait | active | 비고 |
|---|---|---|---|---|
| sequence-email | 225,773 | 0 | 0 | 적체 집중 |
| send-acc-* (490개) | 0 | 0 | 0 | per-account 큐 전부 비어있음 (failed 155) |
| 그 외 전 큐 | ~0 | 0 | 0 | 정상 |
같은 계정의 잡마다 idx × 10초를 누적 가산하는데 상한이 없다. 단일 계정에 enrollment가 집중되면(33k+) idx가 수십만까지 올라 delay가 며칠~75일이 된다.
// staggerDelay 에 상한이 없음 — idx 가 곧 delay(분) const STAGGER_INTERVAL_MS = 10_000 const staggerDelay = baseDelay + acctIdx * STAGGER_INTERVAL_MS // ← cap 부재 // 6,487,500,393ms 관측 = idx 648,750 × 10,000 + jitter(0~1500)
보조: sequence-email-scheduler.ts:100의 MAX_FUTURE_MS cap은 Redis throttle 슬롯만 압축할 뿐, BullMQ delayed의 delay 값은 압축하지 않음(applySchedulingDelay는 jitter만 추가).
const staggerDelay = Math.min(baseDelay + acctIdx*STAGGER_INTERVAL_MS, MAX_FUTURE_MS)모든 적재 경로가 jobId = seq-email-${executionId} 고정. BullMQ addBulk는 같은 jobId가 delayed에 이미 있으면 silently 무시(delay·data 갱신 안 함). 한 번 먼 미래로 박힌 잡은 어떤 경로로도 당길 수 없다 → loader가 매 tick 재적재해도 jobsEnqueued:0.
// getJob(jobId) 가 waiting/delayed/active 면 skip → 갱신 없이 그대로 둠 if (existing && ['waiting','delayed','active'].includes(state)) return // 앞당기기 불가
job.changeDelay(newDelay)로 갱신"으로 변경. 또는 먼 미래 잡을 remove() 후 재적재(데일리 deferral의 active-skip 패턴 차용).deferred 재활성 경로 부재일일 한도 도달 시 그 계정의 오늘자 pending을 전부 deferred로 뒤집고 scheduled_at += 24h. 그러나 loader의 due 쿼리는 WHERE status='pending'만 잡고, deferred → pending을 되돌리는 크론·스케줄러가 코드 전체에 없다. 재진입은 오직 그 execution의 +24h delayed 잡이 fire될 때만 가능 → 그 잡이 원인 ①②로 안 풀리면 영구 정지.
status IN ('pending','deferred') AND scheduled_at < cutoff로 확장하거나, ⓑ 자정/주기 크론으로 scheduled_at <= now()인 deferred를 pending으로 일괄 복원.시퀀스 pause 시 BullMQ 잡은 지우지만 sequence_step_executions.status는 건드리지 않아 overdue pending이 영구 잔존(글로벌 797건). 재개 시 rescheduleOverdueExecutions의 NOW-shift 부하와 완료판정 오염을 유발.
skipped 정책을 시퀀스 pause에도 일관 적용).warmup 발송이 SES로 나가지만 emails가 아닌 warmup_message에만 기록 → 그 메일의 모든 SES 이벤트가 orphan 처리(emails row not found → orphan retry cap reached — deleting). delivered가 emails 통계로 환원되려면 emails.sesMessageId 매칭이 선행돼야 하는데 row가 없음.
ses-event.service의 fallback lookup에 warmup_message.ses_message_id를 추가하거나, untracked 경로도 emails row를 남기도록 통일.PER_ACCOUNT_SEND_ENABLED dual-write로 같은 execution이 양쪽 큐에 적재 → PG atomic claim이 한쪽만 통과, 나머지는 execution.gate에서 "already processed" skip. 이중발송은 막지만 카운트가 부풀려짐. execution.gate.ts:51–57budgeted:claude-sonnet-4-6 호출이 credit balance too low로 실패 + gemini-3-flash 1500ms 타임아웃 반복. personalization/agent 기능 영향.sweepOrphanedApprovedMissions가 미완료 sequences.send_campaign mission을 계속 재enqueue.지금 멈춘 22.5만 잡을 흐르게 하는 1회성 조치:
sequence-email:delayed에서 score가 7일 초과인 잡을 대상 execution의 실제 scheduled_at 기준으로 changeDelay 또는 remove 후 재적재. (128,690건)deferred 복원 — scheduled_at <= now()인 deferred execution을 pending으로 UPDATE → 다음 loader tick이 정상 enqueue.⚠️ 재스케줄 시 stagger를 다시 cap 없이 적용하면 재발 — 반드시 B-①을 먼저 핫픽스하거나, 재주입 시 천장을 건 스크립트로 수행.
| # | 수정 | 파일 |
|---|---|---|
| ① | stagger에 Math.min(…, MAX_FUTURE_MS) 천장 | queues.ts:1944 |
| ② | dedup을 skip → changeDelay() 갱신으로 전환 | queues.ts:1840–1862 |
| ③ | loader SELECT에 deferred 포함 또는 복원 크론 신설 | loader.worker.ts:180 |
①②③은 서로 맞물림 — 셋을 함께 배포해야 "밀림 → 고착 → 영구정지" 사슬이 끊긴다.
④ paused execution 상태 일관화 · ⑤ SES warmup fallback lookup · ⑥ dual-write 단일화 · ⑦ Anthropic 크레딧/폴백. 발송 정지와 독립이므로 A·B 안정화 후 순차 진행.
#4837에서 orphan root로 squash 재임포트돼 그 이전 최초 작성자는 이력에서 소실됨.
| # | sha | 날짜 | PR | author | 무엇을 깨뜨렸나 |
|---|---|---|---|---|---|
| 1 | 626616fdd | 05-19 | #7665 | chlee | 결정타: STAGGER_INTERVAL_MS 400→10,000(25배). cap 없는 idx×interval이 75일 미래까지 폭발 |
| 2 | dfdb1b285 | 04-26 | #5862 | Cheolhee Lee | 근본 분기점: cron-tick loader를 WHERE status='pending'만으로 만들며 deferred→pending 재활성 짝 누락 → daily-limit 잡 영구정지 |
| 3 | 6f2cd3e4e | 04-28 | #6150 | chlee | 결함① 근원: cap 없는 acctIdx × STAGGER_INTERVAL_MS 공식 최초 도입 (worker cap은 BullMQ delay 미보호) |
| 4 | 9cc701f85 | 06-05 | #8191 | chlee | workspace-level deferred write 복제 — reactivator 없이 2번째 정지 경로 추가 |
| 5 | 3603fc815 | 06-05 | #8210 | chlee | per-account adapter가 pending-only 필터 그대로 상속 → 결함을 새 발송 경로로 확산 |
| 6 | 826c33634 | 06-05 | #8220 | chlee | PER_ACCOUNT_SEND_ENABLED canary — 결함을 실제 트래픽에 노출 |
결함②(jobId dedup-skip)는 regression이 아님 — #4837 원설계부터 있던 정상 중복방지 장치인데, #7665가 잡을 수십 일 미래로 밀어내며 "앞당겨 재적재 불가" 사각지대가 드러난 것. changeDelay(#7649)는 수동 step-수정 경로에만 연결, loader 정상 경로엔 미연결.
두 결함은 서로 독립이지만 같은 증상(발송 0)으로 수렴한다:
deferred로 바뀌면 loader가 영영 다시 안 집어듦(write/read가 같은 enum을 영구히 어긋나게 사용). 되돌릴 크론이 코드 전체에 없음.idx×10초가 누적돼 잡이 며칠~75일 미래로 박힘. dedup 때문에 loader가 당겨오지도 못함.왜 6월에 터졌나 — ①②는 4~5월에 잠복해 있었고, 글로벌 린다세일즈가 6월에 단일 계정에 수만 건씩 집중 enrollment(뷰티유통사 33k 등)를 활성화하면서 stagger idx가 임계를 넘고 per-account canary(#8220)가 켜지며 동시 발화. 소규모 시퀀스 시절엔 idx가 작아 잡이 초~분 단위 미래라 자연 소화됐다.
데이터 출처: beta 프로덕션 PG18 + Redis8 BullMQ 실측 · elysia-server 코드 전수분석(3-way) · git 커밋 이력 추적(2-way). 코드 라인·sha는 분석 시점 기준이며 배포 전 재확인 권장.