ES /docs

ES bulk partial index failed (non-recoverable items)

RCA: ES bulk partial index failed (non-recoverable items)

Overview#

What Happened#

2026-07-16 21:25 KST, cupixworks-worker 서비스의 BulkPartialIndexWorkerElementTrace 인덱스에 대해 bulk partial update 를 실행하는 도중, 19개 문서 중 1개가 Elasticsearch version_conflict_engine_exception 으로 실패했다. 워커는 이 에러 타입을 "non-recoverable" 로 분류하고 즉시 BulkPartialIndexDeadWorker (dead-letter) 로 넘겨 재시도 없이 종료했다. Version conflict 는 낙관적 동시성 제어에서 발생하는 전형적인 재시도 가능(retryable) 에러인데, 현재 코드는 document_missing_exception 만 재시도 대상으로 취급한다.

Quick Facts#

Field Value
exception.class version_conflict_engine_exception (ES doc-level, not raised)
exception.message ES bulk partial index failed (non-recoverable items)
top_frame app/workers/bulk_partial_index_worker.rb:82
runtime Ruby (Sidekiq worker), Elasticsearch client bulk API
deploy production-us-west-2-20260715T0858Z0-f2b18e95-cupixworks
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
cupixworks-worker (Editing / ElementTrace 인덱싱) 1 ElementTrace id=133661735editing_id 필드가 ES 에서 최신 상태가 아님. 해당 문서를 참조하는 검색 결과가 stale editing 을 반환할 수 있음.

Timeline#

  1. 2026-07-16 21:25 KSTBulkPartialIndexWorkerElementTrace 19개 id 에 대해 editing_id: 1292143 partial update 실행 (request_id 354d90da8ee043a2a6f6f87b).
  2. 2026-07-16 21:25 KST — ES bulk 응답에서 id=133661735version_conflict_engine_exception 으로 실패. required seqNo 133683718 vs current seqNo 133779569 (동시 업데이트로 인해 seqNo 가 이미 진행됨).
  3. 2026-07-16 21:25 KST — 워커가 document_missing_exception 이외의 에러이므로 BulkPartialIndexDeadWorker 로 넘김. Error 로그 emit (본 클러스터).
  4. 2026-07-16 21:25 KSTBulkPartialIndexDeadWorkerreason: bulk_partial_failed 로 dead-letter 기록 및 tesla.es_bulk_partial.dead.count 메트릭 증가. 재시도 없음.

Error Log#

Datadog Logs

text
ES bulk partial index failed (non-recoverable items)

Impact#

  • Service: cupixworks-worker
  • 발생 횟수: 1
  • 최초 발생: 2026-07-16 21:25 KST
  • 최근 발생: 2026-07-16 21:25 KST

ElementTrace id=133661735 의 ES 문서가 editing_id=1292143 로 업데이트되지 않은 채 dead-letter 상태로 남아있다. RDS 는 BulkPartialSaveJsonToFileWorker 로 별도 갱신되므로 primary source of truth 는 일관성이 유지되나, ES 검색/집계가 stale 데이터를 반환할 가능성이 있다. 단일 문서 이슈로 전체 서비스에 대한 영향은 낮으나, 동일 패턴의 version conflict 가 반복 발생하면 dead-letter 누적이 커진다.

Root Cause Summary#

BulkPartialIndexWorker#perform 은 ES bulk 응답의 per-doc 에러를 두 가지로만 분류한다: document_missing_exceptionDocMissingRetryWorker 로 재시도, 그 외 모든 타입은 즉시 dead-letter. 그러나 version_conflict_engine_exception 은 Elasticsearch 낙관적 동시성 제어(seqNo/primary_term)에서 두 개의 update 가 동일 문서에 동시 도달할 때 발생하는 본질적으로 재시도 가능한 일시 에러다. 본 클러스터에서는 tesla 가 동일 _id 에 대해 BulkPartialIndexWorker (ES 갱신) 와 BulkPartialSaveJsonToFileWorker (JSON 파일 저장) 를 병렬로 enqueue 하는 구조에서 다른 소스의 concurrent update (다른 editing_id fanout 또는 인덱서) 와 seqNo 경쟁이 발생했고, 재시도 로직이 없어 곧바로 dead-letter 로 분류되었다. 즉 root cause 는 재시도 정책의 분류 결함이지, 데이터 손상이나 ES 장애가 아니다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/bulk_partial_index_worker.rb:17BulkPartialIndexWorker#perform(model_name, ids, fields_hash)
  • Bulk 호출: bulk_partial_index_worker.rb:32-34
  • 에러 분류: bulk_partial_index_worker.rb:49-62
  • Failure point (분류 결함): bulk_partial_index_worker.rb:53-61document_missing_exception 만 재시도 후보로 분리하고 나머지는 failed_items 로 이동.
  • Dead-letter enqueue: bulk_partial_index_worker.rb:78-89
app/workers/bulk_partial_index_worker.rb:49-62ruby
Array(response['items']).each do |item|
  op = item.values.first
  next unless op.is_a?(Hash) && op['error']

  if op.dig('error', 'type') == 'document_missing_exception'
    doc_missing_ids << op['_id']
  else
    failed_items << {
      'id' => op['_id'],
      'type' => op.dig('error', 'type'),
      'reason' => op.dig('error', 'reason')
    }
  end
end
app/workers/bulk_partial_index_worker.rb:78-89ruby
if failed_items.any?
  BulkPartialIndexDeadWorker.perform_async(
    model_name, failed_items, fields, 'bulk_partial_failed'
  )
  Cupix::Logger.error('ES bulk partial index failed (non-recoverable items)',
                      class: self.class.name, function: 'perform',
                      model_name: model_name,
                      ids_count: ids.size,
                      failed_count: failed_items.size,
                      fields_keys: fields.keys,
                      failed_items: failed_items)
end

호출자는 Editing 재할당 경로에서 동일 editing_id fanout 을 대량으로 enqueue 한다:

app/services/cupix/editing_split_service.rb:809-819ruby
deferred_jobs << lambda do
  BulkIndexWorker.perform_async('EditingEntity', ee_ids, 'index') if ee_ids.any?
  et_ids.each_slice(1000) do |slice|
    BulkPartialIndexWorker.perform_async('ElementTrace', slice, { 'editing_id' => new_editing.id })
    BulkPartialSaveJsonToFileWorker.perform_async('ElementTrace', slice, { editing_id: new_editing.id }.to_json)
  end
  element_ids.each_slice(1000) do |slice|
    BulkPartialIndexWorker.perform_async('Element', slice, { 'editing_id' => new_editing.id })
    BulkPartialSaveJsonToFileWorker.perform_async('Element', slice, { editing_id: new_editing.id }.to_json)
  end
end

DocMissingRetryWorker 는 재시도 루프를 이미 갖추고 있으므로 (app/workers/doc_missing_retry_worker.rb:12-102), 유사 패턴을 version_conflict_engine_exception 에 확장하면 됨.

기대 동작: version conflict 는 짧은 backoff 후 재시도해야 함 (ES 문서/Elastic 공식 가이드의 표준 처리). 실제 동작: version conflict 가 즉시 dead-letter 로 분류되어 재시도 없음. Sample stat 은 failed_items.first(10) 만 남기고 나머지는 로그에서 유실.

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-worker status:error @environment:production "ES bulk partial index failed (non-recoverable items)"

에러 로그 원문 (attributes 발췌):

json
{
  "message": "ES bulk partial index failed (non-recoverable items)",
  "status": "error",
  "class": "BulkPartialIndexWorker",
  "function": "perform",
  "model_name": "ElementTrace",
  "ids_count": 19,
  "failed_count": 1,
  "fields_keys": ["editing_id"],
  "failed_items": [
    {
      "id": "133661735",
      "type": "version_conflict_engine_exception",
      "reason": "[133661735]: version conflict, required seqNo [133683718], primary term [1]. current document has seqNo [133779569] and primary term [1]"
    }
  ],
  "request_id": "354d90da8ee043a2a6f6f87b",
  "@timestamp": "2026-07-16T12:25:43.276Z"
}

같은 timestamp 에 BulkPartialIndexDeadWorker 가 이어서 실행된 것을 확인 (재시도 없이 dead-letter 기록으로 직행):

text
service:cupixworks-worker "ES bulk partial index dead"
json
{
  "message": "ES bulk partial index dead",
  "class": "BulkPartialIndexDeadWorker",
  "reason": "bulk_partial_failed",
  "model_name": "ElementTrace",
  "failed_count": 1,
  "fields": { "editing_id": 1292143 },
  "sample": [
    {
      "id": "133661735",
      "type": "version_conflict_engine_exception",
      "reason": "[133661735]: version conflict, required seqNo [133683718], primary term [1]. current document has seqNo [133779569] and primary term [1]"
    }
  ],
  "@timestamp": "2026-07-16T12:25:43.276Z"
}

핵심 관찰:

  • ids_count: 19, failed_count: 1 — 슬라이스 19건 중 단 1건만 실패. Cluster-wide ES 장애가 아니라 문서 단위 동시성 충돌.
  • required seqNo 133683718 < current seqNo 133779569 — 워커가 요청을 만든 시점(RDS pluck)과 ES 갱신 시점 사이에 다른 update 가 seqNo 를 이미 진행시켰음을 의미.
  • 같은 request_id 는 없고 두 워커 (BulkPartialIndexWorker, BulkPartialIndexDeadWorker) 가 각각 다른 request_id 로 이어짐 → 재시도 로직이 아니라 dead-letter fanout.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Version conflict 를 재시도 대상에서 제외한 분류 결함 (bulk_partial_index_worker.rb:53) failed_itemsversion_conflict_engine_exception 이 그대로 포함되어 dead-letter 로 이동. 코드상 재시도 분기 없음. Confirmed
H2 Elasticsearch 클러스터 장애 (write 전면 실패) 같은 bulk 응답에서 19건 중 18건은 성공. ES 클러스터 자체는 정상. Rejected
H3 잘못된 payload/스키마 오류 (mapper_parsing_exception) 에러 타입이 명확히 version_conflict_engine_exception. Payload/스키마 이슈가 아님. Rejected
H4 상위 서비스(Editing split) 의 중복 enqueue 로 자체 seqNo 경쟁 유발 editing_split_service.rb:812-813 에서 BulkPartialIndexWorkerBulkPartialSaveJsonToFileWorker 를 병렬 enqueue. 여러 상위 경로 (task/element-level reassign, backfill rake tasks) 가 같은 _id 를 겹쳐서 갱신할 수 있음. seqNo 차이(133683718 vs 133779569)만으로 정확한 경쟁 워커를 특정할 수는 없음. Inconclusive — 재현/추적을 위해 추가 조사 필요
H5 Status-board 의 최근 cupixworks-worker service degraded 인시던트와 동일 원인 7/13, 7/16 에 여러 unknown 클러스터 발생 이력. 이번 클러스터는 단일 발생, active 없음, 별도 fingerprint. 같은 root cause 라는 직접 증거 없음. Rejected (관련 없음)

Fix Recommendation#

즉시 조치 (Critical)#

  • app/workers/bulk_partial_index_worker.rb:53-61 — 에러 분류에 version_conflict_engine_exception 을 재시도 대상으로 추가한다. 접근 방식:
    • 기존 document_missing_exception 분기 옆에 version_conflict_engine_exception 분기를 신설하거나, 재시도 가능한 에러 타입을 whitelist 로 관리 (RETRYABLE_ERROR_TYPES = %w[document_missing_exception version_conflict_engine_exception].freeze).
    • 재시도 워커는 DocMissingRetryWorker 를 확장하거나, 이름을 BulkPartialRetryWorker 로 일반화하여 reason 태그로 원인 구분.
    • Backoff 는 version conflict 특성상 짧은 지연이 유리 (10s / 30s / 2m 정도). DocMissingRetryWorker 의 5/10/20 분 backoff 는 doc_missing 특화이므로 별도 스케줄이 바람직.
    • MAX_ATTEMPTS 를 초과한 경우에만 dead-letter 로 이동.

단기 개선 (1주 이내)#

  • app/workers/doc_missing_retry_worker.rb:41-54 — 재시도 워커의 응답 분류에서도 version_conflict_engine_exception 을 재귀 재시도로 흡수하도록 확장. 현재는 still_missing (retry) vs other_failed (dead) 로만 나뉘어 이차 실행에서 다시 dead-letter 로 새는 경로가 있음.
  • app/workers/bulk_partial_index_worker.rb:82-88 — dead-letter emit 로그의 failed_items 를 항상 첫 10건으로 요약하도록 정렬 (현재 dead worker 의 sample 은 first(10) 이지만 상위 error 로그는 전체를 그대로 실음, PII/로그 크기 관점에서 정합성 확보).
  • BulkPartialIndexDeadWorkerreason:version_conflict 별도 태그를 붙여 tesla.es_bulk_partial.dead.count 메트릭에서 원인별 분해 관측이 가능하도록 함.

장기 개선 (재발 방지)#

  • 상위 경로 (editing_split_service, backfill rake tasks) 에서 동일 _id 를 겹쳐 갱신하는 fanout 이 있는지 감사. 필요시 _id 별 lock 또는 batch coalescing 을 도입해 seqNo 경쟁 자체를 줄임.
  • ES partial update 에 retry_on_conflict 파라미터 (Elasticsearch update API 의 서버 측 자동 재시도) 사용을 검토. client.bulk 요청 시 각 update 액션에 retry_on_conflict: 3 을 추가하면 ES 가 서버 측에서 우선 재시도 후에만 conflict 를 반환한다.
  • Dead-letter 항목을 주기적으로 재-play 하는 admin task 도입 (현재는 로그 조회로만 사후 처리 가능).

Monitoring#

Datadog 쿼리 (release dashboard timeseries widget 용):

text
service:cupixworks-worker status:error @class:BulkPartialIndexWorker "ES bulk partial index failed"
text
service:cupixworks-worker status:error @class:BulkPartialIndexDeadWorker @reason:bulk_partial_failed

Version conflict 특화 관측:

text
service:cupixworks-worker @class:BulkPartialIndexDeadWorker "version_conflict_engine_exception"

메트릭:

text
sum:tesla.es_bulk_partial.dead.count{*} by {model_name,reason}.as_count()

알림 임계: tesla.es_bulk_partial.dead.count 가 5분에 model_name 당 10건 이상이면 warning, 50건 이상이면 error. Fix 배포 후에는 대부분의 version_conflict 가 재시도로 흡수되어야 하므로 dead-letter 유입이 급감하는 것을 그래프로 확인.

Risk Assessment#

  • Risk level: low (단일 문서 이슈, RDS 소스 정보는 온전, ES 문서 하나가 stale 상태)
  • 예상 복잡도: standardDocMissingRetryWorker 패턴을 그대로 재사용 가능하나, 재시도 워커 이름 일반화나 whitelist 도입 시 다른 호출부/spec 도 같이 갱신 필요.