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 서비스의 BulkPartialIndexWorker 가 ElementTrace 인덱스에 대해 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=133661735 의 editing_id 필드가 ES 에서 최신 상태가 아님. 해당 문서를 참조하는 검색 결과가 stale editing 을 반환할 수 있음. |
Timeline#
- 2026-07-16 21:25 KST —
BulkPartialIndexWorker가ElementTrace19개 id 에 대해editing_id: 1292143partial update 실행 (request_id354d90da8ee043a2a6f6f87b). - 2026-07-16 21:25 KST — ES bulk 응답에서
id=133661735가version_conflict_engine_exception으로 실패. required seqNo133683718vs current seqNo133779569(동시 업데이트로 인해 seqNo 가 이미 진행됨). - 2026-07-16 21:25 KST — 워커가
document_missing_exception이외의 에러이므로BulkPartialIndexDeadWorker로 넘김. Error 로그 emit (본 클러스터). - 2026-07-16 21:25 KST —
BulkPartialIndexDeadWorker가reason: bulk_partial_failed로 dead-letter 기록 및tesla.es_bulk_partial.dead.count메트릭 증가. 재시도 없음.
Error Log#
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_exception 은 DocMissingRetryWorker 로 재시도, 그 외 모든 타입은 즉시 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:17—BulkPartialIndexWorker#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-61—document_missing_exception만 재시도 후보로 분리하고 나머지는failed_items로 이동. - Dead-letter enqueue:
bulk_partial_index_worker.rb:78-89
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
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 한다:
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 쿼리:
service:cupixworks-worker status:error @environment:production "ES bulk partial index failed (non-recoverable items)"
에러 로그 원문 (attributes 발췌):
{
"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 기록으로 직행):
service:cupixworks-worker "ES bulk partial index dead"
{
"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 seqNo133779569— 워커가 요청을 만든 시점(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_items 에 version_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 에서 BulkPartialIndexWorker 와 BulkPartialSaveJsonToFileWorker 를 병렬 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) vsother_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/로그 크기 관점에서 정합성 확보).BulkPartialIndexDeadWorker—reason: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 용):
service:cupixworks-worker status:error @class:BulkPartialIndexWorker "ES bulk partial index failed"
service:cupixworks-worker status:error @class:BulkPartialIndexDeadWorker @reason:bulk_partial_failed
Version conflict 특화 관측:
service:cupixworks-worker @class:BulkPartialIndexDeadWorker "version_conflict_engine_exception"
메트릭:
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 상태)
- 예상 복잡도: standard —
DocMissingRetryWorker패턴을 그대로 재사용 가능하나, 재시도 워커 이름 일반화나 whitelist 도입 시 다른 호출부/spec 도 같이 갱신 필요.