Rinda 시퀀스 완료 · reverse 기능 전수분석

작성 2026-06-22 · 발단 에어로웨이1 "동남아시아 566개사 신규 영업" 완료처리중 고착 문의
범위 elysia-server 시퀀스 상태머신 · 완료(forward) · 역방향(reverse) · stranded hygiene · has_pending_work

요약 (TL;DR) 시퀀스 완료는 ① 트리거가 실시간 유지하는 has_pending_work 신호 ② evaluateSequenceCompletion 4-가드 판정 ③ 이벤트 + 5분 배치 두 트리거로 동작한다. forward(active→completed)는 패치 완료(#8920/8921). reverse(completed→active)는 로직은 존재하나 정기 배치 후보에 completed가 빠져 self-heal 사각지대가 있고, 재활성 시 오래된 예약 메일이 발송될 리스크가 있다.

1시퀀스 상태 머신

시퀀스 status enum과 reconcile 대상 분류. isIntentOnlyStatus(reconciler:31)는 reconcile 대상을 active · paused · completed 셋으로 한정한다 — 나머지(draft/ready/archived/generating)는 사용자 의도 상태라 자동 전이하지 않는다.

draft / ready
활성화
active
발송 소진
completed
completed
reverse: live work 재등장
active

forward = "보낼 게 없음(has_pending_work=false) + 답장 윈도우 지남" → completed. reverse = completed인데 보낼 게 다시 생김 → active.

2has_pending_work — "pending 없음"을 매번 어떻게 판단하나

"보낼 게 남았는가"를 부모 sequences 행에 materialized derived 컬럼으로 둔다. 술어는 단 하나, 3중으로 동기화된다.

술어 (SSOT): active/paused enrollment 에
  pending · processing · deferred step execution 이 하나라도 있으면 true
동기화 계층위치동작
① DB 트리거 (실시간)migration 0482/0483sequence_enrollments / sequence_step_executions 의 INSERT·UPDATE·DELETE 마다 AFTER ... FOR EACH STATEMENT 발화 → 영향받은 sequence_id만 DISTINCT 재계산. RETURN NULL로 재귀 차단, IS DISTINCT FROM로 값 바뀐 행만 write.
② self-heal (5분)reconcileHasPendingWork · reconciler:299매 reconcile 호출 끝에 실제값으로 재계산. 트리거 누락(로컬 DB·과거 데이터) 대비 안전망.
③ 백필 (1회)migration 0482 말미기존 1,476 시퀀스 초기화.
실측 검증 (beta, 2026-06-22) active 시퀀스 전수에서 컬럼값 ↔ 실제 pending drift = 0/0. 트리거가 정확히 작동 중 — 완료 판정이 이 컬럼을 신뢰해도 안전.

3forward 완료 판정 — evaluateSequenceCompletion

enrollment-progress.service.ts:649 · 단락평가 4-가드. 발송 시각 게이트는 없다 — pending과 답장만 본다.

1. hasPendingExecutions(active/paused의 미완 exec)  → 있으면 보류
2. hasRecentReply(최근 harvest_days=3일 답장)        → 있으면 보류 (답장 수확)
3. enrollment total=0 + 발송이력/나이 가드            → 활성화 직후 보호
4. 모두 통과 → markCompleted (status='completed')

4"완료 처리 중"(finalizing) 상태는 왜 존재하나

UI의 "완료 처리 중" 배지(campaign-status.ts · isSequenceFinalizing)는 status='active' AND has_pending_work=false 구간이다. 즉 발송은 끝났지만 아직 completed로 넘기지 않은 과도기.

왜 즉시 completed 안 하나

답장 수확 — 마지막 발송 후 답장이 들어올 수 있어 harvest 윈도우 동안 active 유지(추적 단절 방지). ② OOO 자동 재개 — paused(resume_at) enrollment가 깨어나 발송이 재개될 수 있음.

세분화 (getCampaignFinalizingState)

reply_harvest(답장 수신 중) · partially_paused(일시정지 포함) · finalizing(진짜 종료 직전)로 나눠 배지·툴팁을 다르게 표시. 탭 분류는 status='active' 기준이라 "실행 중" 탭에 머문다.

UX 함정 사용자는 "완료 처리 중"을 멈춤/오류로 오해한다(에어로웨이1 문의 발단). 잔여 사유·시간을 노출하면 동일 문의가 준다.

55분 배치 reconciler — reconcileActiveSequencesBatch 패치됨

enrollment-progress.service.ts:416 · worker index.ts:123 (5분 cron)

이전현재 (#8920/8921)
완료 후보 게이트NOT EXISTS(executed_at > now-3d) (발송 시각)has_pending_work = false (정확 신호)
발송완료+답장없음최대 3일 잔존최대 5분 완료
stranded 후보유지: active + 10분↑ pending/deferred
여전히 후보는 status='active' — completed 시퀀스는 이 배치에 절대 안 들어온다. 이것이 §7 reverse 사각지대의 직접 원인.

6reverse (completed → active) 전수분석 결함 있음

completed 시퀀스에 "보낼 게" 다시 나타나면 active로 되돌리는 역방향 전이. reconcileSequenceStatus · reconciler:258-277

if (before === "completed") {
  const hasPending = await hasPendingStepExecutions(sequenceId)
  if (shouldReactivateCompleted(hasPending)) {        // = hasPending (reconciler:40)
    reactivated = await transitionSequenceStatus(
      sequenceId, "completed", "active")              // race-safe CAS (write:?)
  }
}

transitionSequenceStatusWHERE status = expected 조건부 UPDATE라 동시성에 안전(낙관적 CAS) — 현재가 completed일 때만 active로 바뀐다.

reverse 트리거 경로 (호출처 전수)

경로위치completed 평가?
재등록 reEnrollFailedWithValidationlifecycle:489정당 실패 건 재등록 → 새 pending → reverse. 의도된 주 용도.
terminal 전이 직후 updateEnrollmentStatusWithSynclifecycle:447enrollment terminal 전이 시 평가. 보통 pending을 줄이는 방향이라 reverse 발생 드묾.
5분 배치 reconcileActiveSequencesBatchenrollment-progress:452사각지대 후보가 status='active'뿐 → completed 시퀀스는 절대 평가 안 됨.
결함 ① 정기 self-heal 사각지대 재등록 같은 이벤트 경로 외(직접 INSERT·과거 데이터·수동 완료)로 생긴 completed + has_pending_work=true 모순은 어떤 정기 배치도 평가하지 않아 영구 고착. reverse 코드가 있어도 호출되지 않는다.
결함 ② 재활성 시 stale 메일 발송 리스크 reverse 후 다음 hygiene 틱의 decideStrandedResolution(reconciler:52)은 due(예정시각 경과) + sender 살아있음이면 무조건 re-enqueue(발송). 예정시각이 한 달 전이어도 stale 판정이 없어 뒷북 메일이 나간다.

7stranded hygiene — resolveStrandedExecutions

reconciler:88 · active 시퀀스의 10분↑ 묵은 pending/deferred를 정리.

sender 없음(삭제/error/suspended/inactive) → skip   "stranded_no_sender"
sender 있음 + 예정시각 경과(due)            → re-enqueue (멱등 재주입 = 발송)
sender 있음 + 미도래                        → wait (delayed 잡 생존 가정)

awaiting_enrichment는 절대 건드리지 않음(resolve-lead가 park한 상태). 틱당 시퀀스별 500건 상한.

8실측 이슈 데이터 (beta 전수, 2026-06-22)

#이슈규모판정
A완료 고착(active+hpw=false+답장없음)0해소 패치+강제완료
Bhas_pending_work 트리거 drift0 / 0정상
Dcompleted인데 active enrollment+pending2 seq / 84 exec모순 reverse 사각지대. 예정 5/7~5/8(stale)
Corphan: stopped enrollment의 deferred1 / 1경미
Gstuck processing 30분↑7관찰 자동복구 대상
Eoverdue pending46,399 / 9정상 throttle 순차발송 중

D 상세: "Profhilo 바이어 94명"(63건) · "라라필 (20260407)"(21건). 둘 다 completed인데 active enrollment의 pending이 5월 초 예정으로 전부 overdue. 정기 reverse 사각지대(§6 결함①)로 한 달 반째 모순 잔존.

92026 최적 제안

데이터 (즉시·안전)

D 2건·C 1건의 잔존 pending/deferred → skipped 정리 + enrollment 종료. 시퀀스 의도가 completed이므로 종료가 맞고, 5월 stale 메일 발송을 방지. 트리거가 has_pending_work를 자동 false로 동기화.

코드 (재발 방지)

① 배치 후보에 completed AND has_pending_work=true reverse 후보 추가 → self-heal 복원.
stale 가드: decideStrandedResolution에 "예정시각이 harvest_days×N 이상 과거인 due exec은 발송 대신 skip" 추가 → reverse가 뒷북 메일을 쏘지 않게.

실행 순서 주의 코드 패치(reverse 후보 추가)를 먼저 배포하면 D 2건이 즉시 reverse→발송될 수 있다. 반드시 D 데이터 정리를 선행한 뒤 패치, 또는 stale 가드를 같은 PR에 포함.