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#
- 2026-05-21 16:23 KST — Task 4823313 생성 (
TaskSyncWorker#create_tasks_by_elements) - 2026-05-29 15:20 KST —
EditingSplitWorker실행, editing 1129851 split 시작 - 2026-05-29 15:20:31 KST — Phase 4 reconcile에서 orphan ET 감지, template 미발견으로 error 발생
- 2026-05-29 15:20:35 KST — 동일 worker 내 다른 editing(1129844, 1129817)에 대한 split 정상 완료
Error Log#
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#perform→Cupix::EditingSplitService#split!→#do_split! - Split Phase 1-3: 정상 완료 (그룹 계산, 분할 적용, deferred job 실행)
- Phase 4 reconcile 시작 (line 219, deployed commit 83e839b4b)
Phase 4 reconcile의 핵심 로직:
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):
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 발생 지점:
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 쿼리:
service:cupixworks-worker "orphan ET without template" status:error
Error 로그 전문 (raw 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:
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 생성 이력:
service:cupixworks-worker 4823313 from:2026-05-15T00:00:00Z to:2026-05-29T07:00:00Z
{
"timestamp": "2026-05-21T07:23:34.118Z",
"message": "Task ready: 4823313",
"class": "TaskSyncWorker",
"function": "create_tasks_by_elements"
}
Editing 1129851 transition:
{
"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도 포함하되 우선순위를 낮게 설정:
untrashed→trashed순서로 조회 - 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_templatecounter - Datadog 쿼리:
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 로직 보강으로 해결 가능하며, 기존 코드 구조를 크게 변경하지 않아도 됨.