ES /docs

[400] {"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"mapper [analysis_state]

RCA: Capture Elasticsearch put_mapping 400 — analysis_state cannot be changed from text to keyword

Overview#

What Happened#

2026-06-29 03:31 KST (cupixvista, us-west-2) 주간 cron Cupix::Cron::Searchable.partial_reindexCapture 모델의 Elasticsearch 매핑을 갱신하려고 put_mapping 을 호출했으나, ES 가 400 illegal_argument_exception 으로 거절했다. 기존 인덱스에서 analysis_state 필드가 이미 text 타입으로 매핑되어 있어, 코드 매핑이 요구하는 keyword 타입으로 변경할 수 없었기 때문이다. 1회 발생한 단발성 에러이지만 매주 일요일 cron 실행 시 재발할 수 있는 구조적 문제다.

Quick Facts#

Field Value
exception.class Elasticsearch::Transport::Transport::Errors::BadRequest
exception.message [400] {"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"mapper [analysis_state] cannot be changed from type [text] to [keyword]"}], ...}
top_frame app/models/concerns/searchable.rb:354 (Capture.update_mapping!put_mapping)
caller app/models/concerns/searchable.rb:315 (Capture.partial_reindex!)
trigger cron Cupix::Cron::Searchable.partial_reindex (config/schedule.rb:149-151, 30 18 * * 0 UTC)
env production, us-west-2, tenant cupix (cupixvista launch mode)

Affected Teams#

Team / Domain Error Count Impact
Search / Indexing (Capture) 1 partial_reindex! 의 매핑 갱신 단계가 실패. stale document re-indexing 자체는 계속 진행되지만, 신규 mapping 정의(analysis_state: keyword)가 ES 에 반영되지 않아 analysis_state 로 keyword aggregation/sort/filter 가 정확하게 동작하지 않음. 사용자가 직접 보는 에러는 없으나 검색 품질에 잠재 영향.

Timeline#

  1. 2026-06-29 03:30:28 KSTCupix::Cron::Searchable.partial_reindex 시작 (message: "Partial reindex begins", Datadog service:cupixvista-api-worker).
  2. 2026-06-29 03:30:30 / 03:30:38 KST — Partial reindex 가 여러 모델에 대해 순차 진행 (Record, Level, Capture ...).
  3. 2026-06-29 03:31:06 KSTCapture.update_mapping!put_mapping 호출 → ES 400 응답, analysis_state 타입 변경 불가 에러 1건 발생 (@class:Capture, @function:update_mapping!).
  4. 이후 — update_mapping(bang 없는 wrapper)이 StandardError 를 swallow 하여 cron 은 다음 단계(stale_documents 재색인)를 정상 진행. 동일 cron 은 매주 일요일 18:30 UTC 에 재실행 예정.

Error Log#

Datadog Logs

text
[400] {"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"mapper [analysis_state] cannot be changed from type [text] to [keyword]"}],"type":"illegal_argument_exception","reason":"mapper [analysis_state] cannot be changed from type [text] to [keyword]"},"status":400}

Impact#

  • Service: cupixvista-api-worker
  • 발생 횟수: 1 (occurrence_count, 14일 retention 기준 최근 14일에서 1회. 단, cron 이 weekly 이므로 다음 일요일에 재발 가능)
  • 최초 발생: 2026-06-29 03:31 KST
  • 최근 발생: 2026-06-29 03:31 KST

Root Cause Summary#

Capture 의 Elasticsearch 매핑 정의(app/models/concerns/searchable/capture.rb:215)는 analysis_statekeyword 로 선언하지만, cupixvista 테넌트의 기존 ES 인덱스에서 이 필드가 이미 text 로 매핑되어 있다. Elasticsearch 는 한 번 정의된 필드의 타입을 변경할 수 없으므로(put_mapping 의 well-known 제약), 주간 cron Cupix::Cron::Searchable.partial_reindex 가 호출하는 Capture.update_mapping! 가 매번 400 으로 거절된다. 근본 원인은 인덱스 스키마와 코드 스키마의 불일치 — 즉 (a) analysis_state 가 매핑 정의 추가(f52c0578b, TSLA-9851, 2025-07-27) 이전에 동적 매핑(dynamic: 'true', searchable/capture.rb:32)으로 먼저 ES 에 기록되어 기본 text 로 잡혔거나, (b) 과거 다른 마이그레이션에서 의도적으로 text 로 만들어진 상태에서 코드만 keyword 로 바뀌었다.

Technical Analysis#

Code Path#

  • Entry point (cron): config/schedule.rb:149-151 — 매주 일요일 18:30 UTC.
  • Cron body: lib/cupix/cron/searchable.rb:19-22::Capture.partial_reindex! 호출.
  • Mapping update site: app/models/concerns/searchable.rb:315partial_reindex! 첫 줄에서 update_mapping.
  • Wrapper (swallows error): app/models/concerns/searchable.rb:338-344.
  • Failure point: app/models/concerns/searchable.rb:354put_mapping(request) 호출이 ES 400 으로 거절됨.
  • Mapping definition: app/models/concerns/searchable/capture.rb:215indexes 'analysis_state', type: 'keyword'.
config/schedule.rb:149-151ruby
every '30 18 * * 0' do # 18:30 every Sunday
  runner 'Cupix::Cron::Searchable.partial_reindex'
end
lib/cupix/cron/searchable.rb:18-22ruby
begin
  ::Capture.partial_reindex!
rescue StandardError => e
  Cupix::Logger.error("Partial reindex failed on Capture with error: #{e.message}", class: self.name, function: __method__, module: 'Cupix::Cron')
end
app/models/concerns/searchable.rb:314-344ruby
def partial_reindex!
  update_mapping
  # ... stale_documents re-index loop ...
end

def update_mapping
  update_mapping!
rescue StandardError => e
  false
else
  true
end

def update_mapping!
  request = {
    index: __elasticsearch__.index_name,
    body: __elasticsearch__.mappings.to_hash
  }
  request.merge!(type: __elasticsearch__.document_type) if __elasticsearch__.document_type
  self.__elasticsearch__.client.indices.put_mapping(request)
rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
  Cupix::Logger.error(e.message.to_s, class: self.name, function: __method__)
  raise e
end
app/models/concerns/searchable/capture.rb:32, 215-216ruby
mappings dynamic: 'true' do
  # ...
  indexes 'analysis_state', type: 'keyword'
  indexes 'analysis_state_updated_at', type: 'date'

기대 동작: update_mapping! 는 코드의 mapping 정의를 ES 에 그대로 반영. 새 필드는 추가되고, 변경되지 않은 필드는 no-op.

실제 동작: ES 인덱스 cupix.cupix_captures*(또는 cupixvista 테넌트의 동등 인덱스)에 이미 analysis_statetext 로 잡혀 있어, keyword 로 변경을 시도하면 illegal_argument_exception (Elasticsearch 가 string 류 타입을 mutable 하게 다루지 않는 일반 동작). update_mapping! 가 BadRequest 를 로깅하고 re-raise → update_mapping(non-bang) 이 swallow → cron 은 다음 단계(stale_documents 재색인)로 진행. 결과적으로 새 매핑은 반영되지 못한 채 매주 에러 1건이 누적.

왜 ES 가 analysis_statetext 로 들고 있는가 (가장 가능성 높은 시나리오): Capture mapping 은 dynamic: 'true' (searchable/capture.rb:32) 로 동적 매핑을 허용한다. analysis_state 컬럼은 DB 에 2025-07-22 (db/migrate/20250722060131_add_analyze_state_to_captures.rb) 추가되었고, ES 매핑 정의(keyword)는 같은 PR 의 코드 변경분 f52c0578b (TSLA-9851, 2025-07-27) 에 들어갔다. 만약 운영 환경에서 DB 컬럼이 먼저 배포되고 일부 Capture 문서가 새 필드를 포함한 채 ES 로 index 된 뒤(예: _update_documentanalysis_state 를 본문에 포함), 코드 매핑 배포 전이라면 ES 는 그 시점 값(문자열)을 동적으로 text + .keyword 로 잡았다. 이후 코드 매핑이 들어와도 기존 필드 타입은 그대로 — 이것이 정확히 현재 발생 중인 conflict.

(uncertain — needs verification: cupixvista 인덱스의 실제 매핑은 ES 에 직접 GET /<index>/_mapping/field/analysis_state 를 날려 text 인지 확인 필요. 로컬에는 cupixvista ES 접근 수단이 없어 본 RCA 에서는 검증 보류.)

Log Evidence#

Datadog 쿼리:

text
service:cupixvista-api-worker "analysis_state"

해당 시간대에 1건 매치:

json
{
  "timestamp": "2026-06-29 03:31:06 KST",
  "status": "error",
  "service": "cupixvista-api-worker",
  "class": "Capture",
  "function": "update_mapping!",
  "message": "[400] {\"error\":{\"root_cause\":[{\"type\":\"illegal_argument_exception\",\"reason\":\"mapper [analysis_state] cannot be changed from type [text] to [keyword]\"}],\"type\":\"illegal_argument_exception\",\"reason\":\"mapper [analysis_state] cannot be changed from type [text] to [keyword]\"},\"status\":400}"
}

직전 30 초 내 cron 시작 로그가 같은 worker 에서 찍힘 (Datadog service:cupixvista-api-worker "Partial reindex"):

json
{
  "timestamp": "2026-06-29 03:30:28 KST",
  "status": "info",
  "class": "Cupix::Cron::Searchable",
  "function": "partial_reindex",
  "message": "Partial reindex begins"
}

→ cron 시작 (03:30) → Capture.update_mapping! 실패 (03:31) 순서가 명확히 확인됨. cron schedule (30 18 * * 0 UTC = Sunday 18:30 UTC = Monday 03:30 KST) 과 일치.

이후 cron 종료/partial reindex failed on Capture 형태의 wrapper-rescue 로그는 검색되지 않음 → update_mapping(non-bang) 이 BadRequest 를 swallow 해 partial_reindex! 자체는 정상 종료. 다음 단계 stale-document 재색인은 그대로 진행됐을 가능성이 높음.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 ES 인덱스에 analysis_state 가 이미 text 로 매핑되어 있어, 코드 매핑(keyword)으로 put_mapping 시 ES 가 거절. 원인은 dynamic: 'true' 환경에서 매핑 정의 배포 이전/이후 시점에 문자열 값이 먼저 색인된 것. ES 에러 메시지가 mapper [analysis_state] cannot be changed from type [text] to [keyword] 로 정확히 같은 의미를 명시. searchable/capture.rb:32dynamic: 'true', :215keyword 로 정의. f52c0578b (TSLA-9851) 에서 코드 매핑이 추가된 시점과 DB 컬럼 추가 시점(20250722060131) 사이에 5일 간격이 있어 동적 매핑이 선행되었을 충분한 창이 존재. 직접 ES 인덱스 매핑을 확인하지 못함 (cupixvista ES 접근 불가). Confirmed (필드 타입 conflict 는 ES 응답으로 직접 입증; 어떻게 ES 인덱스에 text 가 들어갔는지는 가장 가능성 높은 시나리오로 표시)
H2 reindex 중간에 dual-write 가 발생해 tmp_index 와 본 index 의 매핑이 충돌. searchable.rb:243-262fetch_tmp_index_name 기반 dual-write 로직 존재. 에러 메시지는 단일 필드 analysis_state 의 타입 충돌만 가리키고, tmp_index 관련 메시지(tmp_index, dual write)는 로그에 없음. update_mapping! 는 본 index 만 대상으로 함 (request[:index] = __elasticsearch__.index_name, dual-write 코드 없음). Rejected
H3 일시적 ES 장애/네트워크 이슈로 인한 400. ES 가 반환한 본문이 illegal_argument_exception (의미적 거절)이지 인프라/통신 에러가 아님. 동일 시각에 다른 ES write(예: stale documents 재색인)는 동일 cron 안에서 진행됨이 일반적. Rejected
H4 코드 매핑 정의 자체가 잘못되었고, 의도적으로 text 여야 했다. text 가 기존 인덱스에 잡혀 있다는 사실 자체. analysis_state 는 enum 류 상태값(db/migrate/20250722060131..._add_analyze_state_to_captures.rb:3 기본값 'created')이라 정확 일치 검색/aggregation/sort 가 의미를 가짐 → keyword 가 ES 모범 사례에 부합. 의도는 keyword 가 맞다. Rejected

Fix Recommendation#

즉시 조치 (Critical)#

대상 인덱스의 매핑을 실제로 textkeyword 로 “수정”하는 in-place 변경은 ES 가 지원하지 않으므로, 다음 두 옵션 중 하나를 운영 작업으로 실행:

  • Option A (권장, 인덱스 재구성): cupixvista 환경의 Capture 인덱스에 대해 Capture.reindex_on_migration! (app/models/concerns/searchable.rb:423-435) 또는 동등한 운영 절차(임시 인덱스 생성 → bulk import → alias swap → 구 인덱스 삭제)를 일회성으로 수행. 새로 만든 인덱스는 코드의 keyword 매핑으로 생성되므로 conflict 가 해소된다. 운영 작업이지 코드 변경은 불필요. (메모리 노트: “코드 변경 불필요”인 ops 작업 — 별도 PR/Jira 의 “Operations Hand-off” 로 처리.)
  • Option B (덜 권장, 매핑 정의 변경): app/models/concerns/searchable/capture.rb:215 를 ES 실제 매핑에 맞춰 type: 'text' 로 되돌리고, 필요 시 fields: { keyword: { type: 'keyword' } } 멀티필드를 추가. 단, 이 경우 검색 코드 측에서 analysis_state 를 사용하는 곳이 term/aggs/sort 라면 analysis_state.keyword 로 모두 변경해야 함 (Grep: app/models/concerns/searchable/capture.rb, app/serializers/capture_serializer.rb, controllers 의 search params).

운영 시 어느 환경 인덱스에 conflict 가 있는지 사전 확인:

text
GET /<capture_index_name>/_mapping/field/analysis_state

단기 개선 (1주 이내)#

  • Cupix::Cron::Searchable.partial_reindexupdate_mapping(swallow 버전)을 호출해 mapping 실패가 silent 하게 묻히는 점을 보완. 최소한 cron level 에서 update_mapping! 의 결과(true/false) 를 로깅하고, false 면 warn-level alert. (app/models/concerns/searchable.rb:315, lib/cupix/cron/searchable.rb).
  • 동일 패턴이 다른 모델(Record, Level, Bim, Integration, Editing)에서도 발생할 수 있으므로 cron 1회 실행 시 각 모델의 update_mapping 결과를 함께 로깅해 일괄 진단 가능하도록 한다.

장기 개선 (재발 방지)#

  • dynamic: 'true' 인덱스에서 “DB 컬럼이 ES 매핑 정의보다 먼저 운영에 들어가는” 배포 순서를 막기 위해, ES 매핑이 들어 있는 코드와 DB 마이그레이션을 같은 release 에서 묶도록 release checklist 에 명시(또는 Searchable 모듈에 mapping 정의 hash 와 실제 ES mapping 의 drift 를 시작 시 점검하는 헬스체크 추가).
  • 운영 단계에서 ES schema drift 를 정기적으로 검출하는 모니터링: 매핑 정의 hash(__elasticsearch__.mappings.to_hash) 와 실제 인덱스 매핑(indices.get_mapping) 의 diff 를 비교해 mismatch 가 있으면 warn 로깅하는 가벼운 cron(또는 startup check).

Monitoring#

cupixvista-api-worker 의 Capture.update_mapping! 가 ES 400 으로 실패하는 빈도를 추적:

text
service:cupixvista-api-worker status:error @class:Capture @function:update_mapping!

전체 Searchable cron 의 모델별 매핑 실패(같은 패턴 다른 모델):

text
service:cupixvista-api-worker status:error @function:update_mapping!

cron 자체의 wrapper-level rescue (지금은 swallow 되지만 단기 개선 후 표면화될 메시지):

text
service:cupixvista-api-worker "Partial reindex failed on" @module:Cupix::Cron

Risk Assessment#

  • Risk level: medium — 사용자 facing 에러는 아니지만 매주 재발하며, 매핑이 반영 안 되는 한 analysis_state 기반 keyword 검색/aggregation/sort 가 신뢰할 수 없다. 데이터 일관성 측면의 잠재 영향.
  • 예상 복잡도: standard — 운영 작업(인덱스 재구성)이 핵심이며 코드 변경은 옵션 B 를 선택할 경우에만 필요. Option A 는 운영 자동화/스크립트 단의 일회성 절차.