has_pending_work 신호 ② evaluateSequenceCompletion 4-가드 판정 ③ 이벤트 + 5분 배치 두 트리거로 동작한다.
forward(active→completed)는 패치 완료(#8920/8921). reverse(completed→active)는 로직은 존재하나 정기 배치 후보에 completed가 빠져 self-heal 사각지대가 있고, 재활성 시 오래된 예약 메일이 발송될 리스크가 있다.
시퀀스 status enum과 reconcile 대상 분류. isIntentOnlyStatus(reconciler:31)는 reconcile 대상을 active · paused · completed 셋으로 한정한다 — 나머지(draft/ready/archived/generating)는 사용자 의도 상태라 자동 전이하지 않는다.
forward = "보낼 게 없음(has_pending_work=false) + 답장 윈도우 지남" → completed. reverse = completed인데 보낼 게 다시 생김 → active.
has_pending_work — "pending 없음"을 매번 어떻게 판단하나"보낼 게 남았는가"를 부모 sequences 행에 materialized derived 컬럼으로 둔다. 술어는 단 하나, 3중으로 동기화된다.
술어 (SSOT): active/paused enrollment 에
pending · processing · deferred step execution 이 하나라도 있으면 true
| 동기화 계층 | 위치 | 동작 |
|---|---|---|
| ① DB 트리거 (실시간) | migration 0482/0483 | sequence_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 시퀀스 초기화. |
evaluateSequenceCompletionenrollment-progress.service.ts:649 · 단락평가 4-가드. 발송 시각 게이트는 없다 — pending과 답장만 본다.
1. hasPendingExecutions(active/paused의 미완 exec) → 있으면 보류
2. hasRecentReply(최근 harvest_days=3일 답장) → 있으면 보류 (답장 수확)
3. enrollment total=0 + 발송이력/나이 가드 → 활성화 직후 보호
4. 모두 통과 → markCompleted (status='completed')
UI의 "완료 처리 중" 배지(campaign-status.ts · isSequenceFinalizing)는 status='active' AND has_pending_work=false 구간이다. 즉 발송은 끝났지만 아직 completed로 넘기지 않은 과도기.
① 답장 수확 — 마지막 발송 후 답장이 들어올 수 있어 harvest 윈도우 동안 active 유지(추적 단절 방지). ② OOO 자동 재개 — paused(resume_at) enrollment가 깨어나 발송이 재개될 수 있음.
reply_harvest(답장 수신 중) · partially_paused(일시정지 포함) · finalizing(진짜 종료 직전)로 나눠 배지·툴팁을 다르게 표시. 탭 분류는 status='active' 기준이라 "실행 중" 탭에 머문다.
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 사각지대의 직접 원인.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:?)
}
}
transitionSequenceStatus는 WHERE status = expected 조건부 UPDATE라 동시성에 안전(낙관적 CAS) — 현재가 completed일 때만 active로 바뀐다.
| 경로 | 위치 | completed 평가? |
|---|---|---|
| 재등록 reEnrollFailedWithValidation | lifecycle:489 | 정당 실패 건 재등록 → 새 pending → reverse. 의도된 주 용도. |
| terminal 전이 직후 updateEnrollmentStatusWithSync | lifecycle:447 | enrollment terminal 전이 시 평가. 보통 pending을 줄이는 방향이라 reverse 발생 드묾. |
| 5분 배치 reconcileActiveSequencesBatch | enrollment-progress:452 | 사각지대 후보가 status='active'뿐 → completed 시퀀스는 절대 평가 안 됨. |
completed + has_pending_work=true 모순은 어떤 정기 배치도 평가하지 않아 영구 고착. reverse 코드가 있어도 호출되지 않는다.decideStrandedResolution(reconciler:52)은 due(예정시각 경과) + sender 살아있음이면 무조건 re-enqueue(발송). 예정시각이 한 달 전이어도 stale 판정이 없어 뒷북 메일이 나간다.resolveStrandedExecutionsreconciler: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건 상한.
| # | 이슈 | 규모 | 판정 |
|---|---|---|---|
| A | 완료 고착(active+hpw=false+답장없음) | 0 | 해소 패치+강제완료 |
| B | has_pending_work 트리거 drift | 0 / 0 | 정상 |
| D | completed인데 active enrollment+pending | 2 seq / 84 exec | 모순 reverse 사각지대. 예정 5/7~5/8(stale) |
| C | orphan: stopped enrollment의 deferred | 1 / 1 | 경미 |
| G | stuck processing 30분↑ | 7 | 관찰 자동복구 대상 |
| E | overdue pending | 46,399 / 9 | 정상 throttle 순차발송 중 |
D 상세: "Profhilo 바이어 94명"(63건) · "라라필 (20260407)"(21건). 둘 다 completed인데 active enrollment의 pending이 5월 초 예정으로 전부 overdue. 정기 reverse 사각지대(§6 결함①)로 한 달 반째 모순 잔존.
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가 뒷북 메일을 쏘지 않게.