RINDA · Sequence 진단 · 수정 · 배포

시퀀스 "완료 처리 중" stuck — 종합 리포트

beta·alpha 실데이터 · 2026-06-11 · 발단 019e8622-3606-7a4e-b028-7f03d022c215

✓ alpha #8477 MERGED ✓ beta #8479 MERGED ✓ 백필 beta 802 · alpha 3 ✓ orphan 0 검증

TL;DR

  1. "완료 처리 중"은 DB는 active인데 백엔드가 completed로 못 넘긴 모든 경우의 공통 표시. 두 종류가 섞여 있었음.
  2. 버그 아님 — 답장이 계속 들어와 reply-harvest 3일 가드에 정상 보류된 대형 캠페인(답장 멎으면 자동 완료).
  3. 진짜 버그 — enrollment가 끝났는데(terminal) 남은 pending/deferred execution orphan이 완료 가드를 영구 TRUE로 만들어 최장 21일 stuck. beta 802건.
  4. 해결 — 완료 가드를 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건을 가드별로 분해하자 원인이 두 갈래로 나뉘었다.

시퀀스stuckg1 pendingg2 답장(3d)진단
AI 캠페인·6/2 (발단)3.1d031정상 보류(답장 수확)
싱가폴·말레이시아 / 오세아니아 / 동남아~3d02~7정상
D10_1/210.9d01답장 띄엄띄엄
스페로네 200명21.1d10orphan 버그
베트남 유아헤어밴드8.2d2400paused(보존 정상)

2. "completed" 전환 기준 — 2단계로 분리해야 정확

핵심 혼동 지점. completed는 레벨이 둘이고, 이번 stuck은 ②가 안 되는 것이다.

레벨의미SSOT판정 시점
enrollment.status='completed'리드 1명에게 캠페인 끝남sequence_enrollments.status발송/답장 이벤트 직후(동기)
sequence.status='completed'캠페인 전체 끝남sequences.statusenrollment 종료 시 재평가

① Enrollment → completed 기준 (리드 단위) — 본질은 "더 보낼 스텝이 없다"

경로조건위치
마지막 스텝 발송 성공stepOrder >= steps.lengthenrollment-progress:319
마지막 스텝 발송 실패실패해도 후속 스텝 없음:193
답장 자동화 complete 액션답장 분류=긍정/미팅 → 'complete'reply-automation
잔여 스텝 0그룹 자동등록 후 보낼 스텝 없음sequence-auto-enroll:305
이것이 skip이 안전한 근거 enrollment가 completed면 정의상 "더 발송 안 함". 따라서 그 시점에 남은 pending/deferred execution은 보내면 안 되는 좀비이므로 skipped로 정리하는 게 정합적이다. 단 paused는 "나중에 재개"라 보존 — 그래서 completed 전이에서만 skip한다.

② Sequence → completed 기준 (캠페인 단위) — stuck의 핵심

evaluateSequenceCompletion3가드를 모두 통과해야 markCompleted:

가드통과 조건보류(=완료 처리 중) 사유
#1 pendingpending/processing/deferred execution 없음pending-executions
#2 reply최근 3일(REPLY_HARVEST_DAYS) 답장 없음recent-reply
#3 emptyenrollment 0이면 마지막 발송 3일 경과empty-recent-emails

발단 캠페인은 가드#2로 정상 보류 중이었다(답장이 계속 옴) — 버그 아님. 손대지 않았다.

3. 근본 원인 — orphan execution

evaluateSequenceCompletion(순수 도메인)과 SequenceCompletionDeps(port)·productionDeps(adapter)는 이미 헥사고날 구조였다. 버그는 두 곳:

  1. 가드#1이 enrollment 상태를 안 봤다 — execution 상태만 셌다. enrollment가 이미 terminal인데 execution만 좀비로 남으면 가드#1이 영구 TRUE → 시퀀스가 ②로 영원히 못 감.
  2. completed 전이가 정리를 안 했다 — chokepoint(updateEnrollmentStatusWithSync)의 isTerminalStop에 completed가 빠져 있었고, 게다가 completed 전이는 대부분 chokepoint를 우회(enrollment-progress 직접 UPDATE).
모순 데이터 (beta 실측) 스페로네: 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 delayed96,074 (전부 미래 예약)정상
stuck delayed(score 과거)0큐 묶임 없음
orphan 3건 BullMQ EXISTS전부 0DB-only 좀비
메모리 / eviction / throttle leak1.23G/8G · 0 · 0정상

정합성이 단방향이었던 것이 원인: DB→BullMQ(cancel)는 있지만 job 소멸→DB status 동기화가 없어 DB에만 좀비가 남았다. cancelEnrollmentJobspending만 조회하던 점도 deferred 누락에 기여. 단 실측상 job은 이미 사라져(EXISTS=0) Redis 정리는 불필요.

5. 코드 수정 — 헥사고날 경계 존중 (머지 완료)

도메인 함수 evaluateSequenceCompletion은 불변. ① 가드는 adapter SQL만, ② skip은 재사용 도메인 헬퍼로 추출. 5개 지점:

#수정파일성격
1완료 가드에 AND se.status IN ('active','paused')enrollment-progress (adapter)근본 안전망
2재사용 헬퍼 skipRemainingExecutionsOnComplete()enrollment-progressport/도메인
3completed 직접 전이 2경로에서 헬퍼 호출enrollment-progress:208·328위생
4chokepoint에 || 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"])))
모든 케이스 커버 enrollment가 completed 되는 모든 경로(직접 2 + chokepoint 경유 + auto-enroll) + 어디서 새도 막는 가드#1 안전망. paused는 어디서도 skip 안 함(resume 보존), processing 제외(worker 처리 중) → 회귀 차단. 검증: type-check 0, 번들 build pass, sequence-completion-evaluator 10 pass.

6. DB / Redis 수정 계획 + 실행 결과

DB 백필 완료

멱등 SQL — terminal enrollment의 pending/deferredskipped로. 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 교집합