시퀀스 "완료 처리 중" stuck — 종합 리포트
TL;DR
- "완료 처리 중"은 DB는
active인데 백엔드가completed로 못 넘긴 모든 경우의 공통 표시. 두 종류가 섞여 있었음. - 버그 아님 — 답장이 계속 들어와 reply-harvest 3일 가드에 정상 보류된 대형 캠페인(답장 멎으면 자동 완료).
- 진짜 버그 — enrollment가 끝났는데(terminal) 남은
pending/deferredexecution orphan이 완료 가드를 영구 TRUE로 만들어 최장 21일 stuck. beta 802건. - 해결 — 완료 가드를 enrollment 상태 기준으로 고치고(모든 경로 안전망) + 모든 completed 전이에서 잔존 execution 정리. alpha·beta 머지 + 802/3 백필 완료. Redis는 손댈 게 없었음(DB-only orphan).
1. 맥락 — 어떻게 발견했나
app.rinda.ai/sequences(=beta)에서 특정 캠페인이 "완료 처리 중" 배지에서 며칠째 풀리지 않는다는 관찰로 시작.
프론트(campaign-status.ts:70)는 status='active' AND total>0 AND active_enrollments=0일 때 이 배지를 띄우므로,
이는 백엔드가 시퀀스를 completed로 전이시키지 못한 모든 상태를 한 화면에 보여준다.
beta 실데이터로 stuck 시퀀스 7건을 가드별로 분해하자 원인이 두 갈래로 나뉘었다.
| 시퀀스 | stuck | g1 pending | g2 답장(3d) | 진단 |
|---|---|---|---|---|
| AI 캠페인·6/2 (발단) | 3.1d | 0 | 31 | 정상 보류(답장 수확) |
| 싱가폴·말레이시아 / 오세아니아 / 동남아 | ~3d | 0 | 2~7 | 정상 |
| D10_1/2 | 10.9d | 0 | 1 | 답장 띄엄띄엄 |
| 스페로네 200명 | 21.1d | 1 | 0 | orphan 버그 |
| 베트남 유아헤어밴드 | 8.2d | 240 | 0 | paused(보존 정상) |
2. "completed" 전환 기준 — 2단계로 분리해야 정확
핵심 혼동 지점. completed는 레벨이 둘이고, 이번 stuck은 ②가 안 되는 것이다.
| 레벨 | 의미 | SSOT | 판정 시점 |
|---|---|---|---|
| enrollment.status='completed' | 리드 1명에게 캠페인 끝남 | sequence_enrollments.status | 발송/답장 이벤트 직후(동기) |
| sequence.status='completed' | 캠페인 전체 끝남 | sequences.status | enrollment 종료 시 재평가 |
① Enrollment → completed 기준 (리드 단위) — 본질은 "더 보낼 스텝이 없다"
| 경로 | 조건 | 위치 |
|---|---|---|
| 마지막 스텝 발송 성공 | stepOrder >= steps.length | enrollment-progress:319 |
| 마지막 스텝 발송 실패 | 실패해도 후속 스텝 없음 | :193 |
| 답장 자동화 complete 액션 | 답장 분류=긍정/미팅 → 'complete' | reply-automation |
| 잔여 스텝 0 | 그룹 자동등록 후 보낼 스텝 없음 | sequence-auto-enroll:305 |
completed면 정의상 "더 발송 안 함". 따라서 그 시점에 남은 pending/deferred execution은
보내면 안 되는 좀비이므로 skipped로 정리하는 게 정합적이다.
단 paused는 "나중에 재개"라 보존 — 그래서 completed 전이에서만 skip한다.
② Sequence → completed 기준 (캠페인 단위) — stuck의 핵심
evaluateSequenceCompletion이 3가드를 모두 통과해야 markCompleted:
| 가드 | 통과 조건 | 보류(=완료 처리 중) 사유 |
|---|---|---|
| #1 pending | pending/processing/deferred execution 없음 | pending-executions |
| #2 reply | 최근 3일(REPLY_HARVEST_DAYS) 답장 없음 | recent-reply |
| #3 empty | enrollment 0이면 마지막 발송 3일 경과 | empty-recent-emails |
발단 캠페인은 가드#2로 정상 보류 중이었다(답장이 계속 옴) — 버그 아님. 손대지 않았다.
3. 근본 원인 — orphan execution
evaluateSequenceCompletion(순수 도메인)과 SequenceCompletionDeps(port)·productionDeps(adapter)는
이미 헥사고날 구조였다. 버그는 두 곳:
- 가드#1이 enrollment 상태를 안 봤다 — execution 상태만 셌다. enrollment가 이미 terminal인데 execution만 좀비로 남으면 가드#1이 영구 TRUE → 시퀀스가 ②로 영원히 못 감.
- completed 전이가 정리를 안 했다 — chokepoint(
updateEnrollmentStatusWithSync)의isTerminalStop에 completed가 빠져 있었고, 게다가 completed 전이는 대부분 chokepoint를 우회(enrollment-progress직접 UPDATE).
pending execution 1건인데 enrollment는 completed. 베트남: deferred 240건인데 enrollment는 paused(이건 보존이 정상).
terminal enrollment + 미처리 execution = beta 802건 / alpha 3건.
4. Redis / BullMQ 분석 — 인프라는 멀쩡
orphan이 Redis 좀비인지 확인했고, 전부 DB-only였다. Redis는 손댈 게 없었다.
| 지표 | 값 | 판정 |
|---|---|---|
sequence-email delayed | 96,074 (전부 미래 예약) | 정상 |
| stuck delayed(score 과거) | 0 | 큐 묶임 없음 |
orphan 3건 BullMQ EXISTS | 전부 0 | DB-only 좀비 |
| 메모리 / eviction / throttle leak | 1.23G/8G · 0 · 0 | 정상 |
정합성이 단방향이었던 것이 원인: DB→BullMQ(cancel)는 있지만 job 소멸→DB status 동기화가 없어 DB에만 좀비가 남았다.
cancelEnrollmentJobs가 pending만 조회하던 점도 deferred 누락에 기여. 단 실측상 job은 이미 사라져(EXISTS=0) Redis 정리는 불필요.
5. 코드 수정 — 헥사고날 경계 존중 (머지 완료)
도메인 함수 evaluateSequenceCompletion은 불변. ① 가드는 adapter SQL만, ② skip은 재사용 도메인 헬퍼로 추출. 5개 지점:
| # | 수정 | 파일 | 성격 |
|---|---|---|---|
| 1 | 완료 가드에 AND se.status IN ('active','paused') | enrollment-progress (adapter) | 근본 안전망 |
| 2 | 재사용 헬퍼 skipRemainingExecutionsOnComplete() | enrollment-progress | port/도메인 |
| 3 | completed 직접 전이 2경로에서 헬퍼 호출 | enrollment-progress:208·328 | 위생 |
| 4 | chokepoint에 || status==='completed' 추가 | sequence-lifecycle:401 | 경유 경로 일괄 |
| 5 | 잔여 스텝 0 완료 경로에서 헬퍼 호출 | sequence-auto-enroll:305 | 위생 |
// 수정 1 — 완료 가드 (adapter SQL): terminal orphan 은 완료를 못 막게 FROM sequence_step_executions sse INNER JOIN sequence_enrollments se ON sse.enrollment_id = se.id WHERE se.sequence_id = ${sequenceId} + AND se.status IN ('active', 'paused') AND sse.status IN ('pending', 'processing', 'deferred') // 수정 4 — chokepoint: completed 도 정리 대상 (paused 만 제외) - if (isTerminalStop) { + if (isTerminalStop || status === "completed") { await tx.update(sequenceStepExecutions) .set({ status: "skipped", errorMessage: skipReason }) .where(and(eq(...enrollmentId), inArray(...["pending","deferred"])))
processing 제외(worker 처리 중) → 회귀 차단.
검증: type-check 0, 번들 build pass, sequence-completion-evaluator 10 pass.
6. DB / Redis 수정 계획 + 실행 결과
DB 백필 완료
멱등 SQL — terminal enrollment의 pending/deferred만 skipped로. paused·processing 제외.
UPDATE sequence_step_executions sse
SET status='skipped', error_message=...
FROM sequence_enrollments se
WHERE sse.enrollment_id=se.id
AND se.status IN ('completed','stopped',
'bounced','unsubscribed')
AND sse.status IN ('pending','deferred');
beta 802건 · alpha 3건 처리 → 재검증 orphan 0 rows.
Redis 작업 없음
orphan은 BullMQ에 EXISTS=0(DB-only) — Redis 정리·스케일·파라미터 조정 전부 불필요.
delayed 96k·메모리·eviction·throttle 모두 정상. 인프라 비용 0.
배포된 새 코드의 완료 재평가(이벤트 또는 1시간 stale-recheck cron)가 백필된 시퀀스를
completed로 자동 전이시킨다. 답장 수확 중인 캠페인은 가드#2로 계속 정상 보류.
7. 이슈 / 회귀 검증
| 점검 | 결과 |
|---|---|
| paused enrollment의 deferred 보존 (resume 발송) | ✓ 모든 skip은 terminal/completed에서만. 백필도 paused 제외 |
| processing execution (worker 처리 중) | ✓ 전 경로·백필에서 제외 |
| 가드#1 변경 후 paused 시퀀스 | ✓ 여전히 카운트 → 정상 보류 유지 |
| type-check / 번들 / 도메인 테스트 | ✓ 0 err / pass / 10 pass |
| CI 무관 실패(ai-sdk type, flaky unit) | 기존 이슈 로컬 의존성 stale + agent 모듈 flaky, 본 변경과 0 교집합 |