ES /docs

Phase 4 reconcile: orphan ET without template

RCA: Phase 4 reconcile: orphan ET without template

Overview#

What Happened#

2026-05-29 15:20 KST, cupixworks-worker 서비스의 Cupix::EditingSplitService#do_split! Phase 4 reconcile 단계에서 editing 1129851에 대해 orphan ElementTrace가 발견되었으나, EE(EditingEntity) 합성을 위한 template을 찾지 못해 error 로그가 발생했다. task_id 4823313에 대해 record_id: nil이 아닌 EditingEntity가 시스템 전체에 존재하지 않아 template synthesis가 실패한 것이다.

Quick Facts#

Field Value
exception.class Cupix::EditingSplitService (application-level error log)
exception.message Phase 4 reconcile: orphan ET without template
top_frame app/services/cupix/editing_split_service.rb:271 (deployed commit c3e0a0aa6)
deploy production-us-west-2-20260529T0413Z0-018399f8-cupixworks
env production, us-west-2

Timeline#

  1. 2026-05-21 16:23 KST — Task 4823313 생성 (TaskSyncWorker#create_tasks_by_elements)
  2. 2026-05-29 15:20 KSTEditingSplitWorker 실행, editing 1129851 split 시작
  3. 2026-05-29 15:20:31 KST — Phase 4 reconcile에서 orphan ET 감지, template 미발견으로 error 발생
  4. 2026-05-29 15:20:35 KST — 동일 worker 내 다른 editing(1129844, 1129817)에 대한 split 정상 완료

Error Log#

Datadog Logs

text
Phase 4 reconcile: orphan ET without template

Impact#

  • Service: cupixworks-worker
  • 발생 횟수: 1
  • 최초 발생: 2026-05-29 15:20 KST
  • 최근 발생: 2026-05-29 15:20 KST

해당 orphan ET(task_id: 4823313, record_id: 130469)는 EE가 합성되지 않았으므로, editing 1129851의 stat_total_entities가 실제 ET 수와 일치하지 않을 수 있으며, 해당 Task에 대한 SQA 작업 항목이 Editing UI에 표시되지 않을 가능성이 있다. 단, 226개 ET 중 1건이므로 사용자 영향은 제한적이다.

Root Cause Summary#

Phase 4 reconcile의 orphan ET sweep 로직에서, et_counts에는 (task_id: 4823313, record_id: 130469) 쌍이 존재하지만 해당 editing의 untrashed EditingEntity에는 이 쌍이 없어 "orphan"으로 판정되었다. Template synthesis는 동일 task_id를 가진 record_id IS NOT NULL인 EditingEntity를 찾아 속성을 복사하는데, task 4823313에 대해 local(editing 1129851 내)과 global(시스템 전체) 모두에서 record_id가 non-nil인 untrashed EditingEntity가 존재하지 않아 template이 nil로 반환되었다. 이는 해당 Task의 모든 EE가 이전 split 사이클에서 trashed 되었거나, 애초에 record_id: nil로만 생성된 경우에 발생하는 edge case이다.

Technical Analysis#

Code Path#

  • Entry point: EditingSplitWorker#performCupix::EditingSplitService#split!#do_split!
  • Split Phase 1-3: 정상 완료 (그룹 계산, 분할 적용, deferred job 실행)
  • Phase 4 reconcile 시작 (line 219, deployed commit 83e839b4b)

Phase 4 reconcile의 핵심 로직:

app/services/cupix/editing_split_service.rb:239-263 (commit 83e839b4b)ruby
et_counts = ::ElementTrace.where(editing_id: ed.id).group(:task_id, :record_id).count
untrashed_remaining = 0
::EditingEntity.where(editing_id: ed.id, entity_type: 'Task').untrashed.find_each do |ee|
  et_count = et_counts[[ee.entity_id, ee.record_id]] || 0
  # ... EE count 조정 또는 trashing ...
end

existing_pairs = ::EditingEntity.where(editing_id: ed.id, entity_type: 'Task')
                                .untrashed
                                .pluck(:entity_id, :record_id).to_set
orphan_pairs = is_result_scope ? et_counts.reject { |(tid, rid), _| rid.nil? || existing_pairs.include?([tid, rid]) } : {}

Orphan 감지: et_counts(4823313, 130469) 쌍이 있지만, existing_pairs(untrashed EE)에는 없으므로 orphan으로 분류된다.

Template lookup (failure point):

app/services/cupix/editing_split_service.rb:265-276 (commit 83e839b4b)ruby
orphan_task_ids = orphan_pairs.keys.map(&:first).uniq
local_templates_by_task = ::EditingEntity.where(editing_id: ed.id, entity_type: 'Task', entity_id: orphan_task_ids)
                                         .where.not(record_id: nil)
                                         .order(:record_id, :id)
                                         .group_by(&:entity_id)
                                         .transform_values(&:first)
missing_task_ids = orphan_task_ids - local_templates_by_task.keys
global_templates_by_task = if missing_task_ids.any?
                             ::EditingEntity.where(entity_type: 'Task', entity_id: missing_task_ids)
                                            .where.not(record_id: nil)
                                            .untrashed
                                            .order(:record_id, :id)
                                            .group_by(&:entity_id)
                                            .transform_values(&:first)
                           else
                             {}
                           end

기대 동작: local 또는 global에서 동일 task_id를 가진 non-nil record_id의 EE를 찾아 template으로 사용. 실제 동작: task 4823313에 대해 양쪽 모두 nil 반환 → error 로그 후 skip.

Error 발생 지점:

app/services/cupix/editing_split_service.rb:283-290 (commit 83e839b4b)ruby
orphan_pairs.each do |(task_id, record_id), et_count|
  template_ee = local_templates_by_task[task_id] || global_templates_by_task[task_id]

  if template_ee.nil?
    Cupix::Logger.error('Phase 4 reconcile: orphan ET without template',
                        class: self.class.name, function: __method__,
                        editing_id: ed.id, task_id: task_id, record_id: record_id,
                        et_count: et_count)
    next
  end

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-worker "orphan ET without template" status:error

Error 로그 전문 (raw JSON):

json
{
  "level": "error",
  "task_id": 4823313,
  "editing_id": 1129851,
  "record_id": 130469,
  "et_count": 226,
  "class": "Cupix::EditingSplitService",
  "function": "do_split!",
  "message": "Phase 4 reconcile: orphan ET without template",
  "@timestamp": "2026-05-29T06:20:31.216Z",
  "request_id": "9c77713cfde7caee38094927",
  "tenant": "cupix",
  "environment": "production"
}

동일 시간대 context:

text
service:cupixworks-worker "Phase 4 reconcile" from:2026-05-29T05:20:00Z to:2026-05-29T07:00:00Z

결과: error 1건, info 다수 (Phase 4 reconcile scope expanded). 동일 시간대 warn(recovered) 로그는 0건 — 이 editing에서는 다른 orphan recovery는 발생하지 않았다.

Task 4823313 생성 이력:

text
service:cupixworks-worker 4823313 from:2026-05-15T00:00:00Z to:2026-05-29T07:00:00Z
json
{
  "timestamp": "2026-05-21T07:23:34.118Z",
  "message": "Task ready: 4823313",
  "class": "TaskSyncWorker",
  "function": "create_tasks_by_elements"
}

Editing 1129851 transition:

json
{
  "timestamp": "2026-05-29T06:20:15Z",
  "message": "state has transitioned from waiting to ready on Editing 1129851"
}

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Task 4823313의 모든 EE가 이전 split에서 trashed되어 template 부재 error 로그에 et_count: 226이지만 template nil. local/global 모두 where.not(record_id: nil).untrashed 조건으로 조회하므로, 모든 EE가 trashed면 결과 없음 Confirmed
H2 Task 4823313에 대한 EE가 애초에 record_id: nil로만 생성됨 orphan_pairs 판정 시 rid.nil?인 쌍은 제외되므로, orphan으로 잡힌 쌍의 record_id는 130469(non-nil). 하지만 template lookup은 동일 task_id의 다른 EE에서 찾으므로 해당 task의 EE가 모두 nil이면 실패 Task가 TaskSyncWorker#create_tasks_by_elements로 정상 생성되었으므로 EE도 정상 경로로 생성되었을 가능성이 높으나, 이전 split에서 trashed될 수 있음 Inconclusive (H1과 결합)
H3 Race condition — 다른 worker가 동시에 EE를 trashed 처리 Redis lock으로 동일 editing에 대한 동시 실행은 방지됨. 하지만 global template lookup은 다른 editing의 EE를 참조하므로, 다른 editing의 split이 동시에 EE를 trashing할 수 있음 error가 1건뿐이고, global lookup은 .untrashed 조건으로 조회하므로 timing window는 매우 좁음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 파일: app/services/cupix/editing_split_service.rb (commit 83e839b4b 기준 line 266-276)
  • 방향: global template lookup에서 .untrashed 조건을 제거하거나, trashed EE도 fallback template으로 사용할 수 있도록 변경. 또는 orphan ET 자체가 trashed task에 속하는 경우 error 대신 warn으로 처리하고 해당 ET를 정리(trash)하는 로직 추가.
  • 근거: template이 없는 orphan ET는 현재 그냥 skip되어 데이터 정합성 불일치가 남는데, trashed EE를 template으로 허용하면 합성이 가능하고, 불가능한 경우에도 해당 ET를 명시적으로 처리(trash 또는 warning)하여 데이터 정합성을 유지할 수 있다.

단기 개선 (1주 이내)#

  • Global template fallback 단계에서 trashed EE도 포함하되 우선순위를 낮게 설정: untrashedtrashed 순서로 조회
  • Error → warn 레벨 변경 검토: template 없이 orphan ET가 남는 것은 데이터 불일치이지만, 226개 ET 중 1건의 orphan이 즉시 서비스 장애를 유발하지는 않으므로 severity 조정 가능

장기 개선 (재발 방지)#

  • Phase 4 reconcile에서 "recoverable vs unrecoverable orphan" 분류 체계 도입
  • Unrecoverable orphan의 경우 해당 ET를 orphaned 상태로 마킹하여 추후 수동 검토 가능하도록 audit trail 추가
  • Split 과정에서 EE trashing 시 관련 ET의 orphan 가능성을 사전 점검하는 guard 추가

Monitoring#

  • 추가할 메트릭: editing_split.phase4_orphan_without_template counter
  • Datadog 쿼리:
text
service:cupixworks-worker "orphan ET without template" status:error
  • 해당 쿼리에 대한 alert threshold: 5건/1시간 이상 시 PagerDuty notify (현재 14일간 1건이므로 매우 드문 케이스)

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard
  • 근거: 14일간 단 1건 발생. 226개 ET 중 orphan 1건으로 사용자 영향 미미. Template fallback 로직 보강으로 해결 가능하며, 기존 코드 구조를 크게 변경하지 않아도 됨.