[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_reindex 가 Capture 모델의 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#
- 2026-06-29 03:30:28 KST —
Cupix::Cron::Searchable.partial_reindex시작 (message: "Partial reindex begins", Datadogservice:cupixvista-api-worker). - 2026-06-29 03:30:30 / 03:30:38 KST — Partial reindex 가 여러 모델에 대해 순차 진행 (
Record,Level,Capture...). - 2026-06-29 03:31:06 KST —
Capture.update_mapping!가put_mapping호출 → ES 400 응답,analysis_state타입 변경 불가 에러 1건 발생 (@class:Capture,@function:update_mapping!). - 이후 —
update_mapping(bang 없는 wrapper)이StandardError를 swallow 하여 cron 은 다음 단계(stale_documents재색인)를 정상 진행. 동일 cron 은 매주 일요일 18:30 UTC 에 재실행 예정.
Error Log#
[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_state 를 keyword 로 선언하지만, 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:315—partial_reindex!첫 줄에서update_mapping. - Wrapper (swallows error):
app/models/concerns/searchable.rb:338-344. - Failure point:
app/models/concerns/searchable.rb:354—put_mapping(request)호출이 ES 400 으로 거절됨. - Mapping definition:
app/models/concerns/searchable/capture.rb:215—indexes 'analysis_state', type: 'keyword'.
every '30 18 * * 0' do # 18:30 every Sunday
runner 'Cupix::Cron::Searchable.partial_reindex'
end
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
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
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_state 가 text 로 잡혀 있어, keyword 로 변경을 시도하면 illegal_argument_exception (Elasticsearch 가 string 류 타입을 mutable 하게 다루지 않는 일반 동작). update_mapping! 가 BadRequest 를 로깅하고 re-raise → update_mapping(non-bang) 이 swallow → cron 은 다음 단계(stale_documents 재색인)로 진행. 결과적으로 새 매핑은 반영되지 못한 채 매주 에러 1건이 누적.
왜 ES 가 analysis_state 를 text 로 들고 있는가 (가장 가능성 높은 시나리오): 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_document 가 analysis_state 를 본문에 포함), 코드 매핑 배포 전이라면 ES 는 그 시점 값(문자열)을 동적으로 text + .keyword 로 잡았다. 이후 코드 매핑이 들어와도 기존 필드 타입은 그대로 — 이것이 정확히 현재 발생 중인 conflict.
(uncertain — needs verification: cupixvista 인덱스의 실제 매핑은 ES 에 직접 GET /<index>/_mapping/field/analysis_state 를 날려 text 인지 확인 필요. 로컬에는 cupixvista ES 접근 수단이 없어 본 RCA 에서는 검증 보류.)
Log Evidence#
Datadog 쿼리:
service:cupixvista-api-worker "analysis_state"
해당 시간대에 1건 매치:
{
"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"):
{
"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:32 에 dynamic: 'true', :215 에 keyword 로 정의. f52c0578b (TSLA-9851) 에서 코드 매핑이 추가된 시점과 DB 컬럼 추가 시점(20250722060131) 사이에 5일 간격이 있어 동적 매핑이 선행되었을 충분한 창이 존재. |
직접 ES 인덱스 매핑을 확인하지 못함 (cupixvista ES 접근 불가). | Confirmed (필드 타입 conflict 는 ES 응답으로 직접 입증; 어떻게 ES 인덱스에 text 가 들어갔는지는 가장 가능성 높은 시나리오로 표시) |
| H2 | reindex 중간에 dual-write 가 발생해 tmp_index 와 본 index 의 매핑이 충돌. | searchable.rb:243-262 에 fetch_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)#
대상 인덱스의 매핑을 실제로 text → keyword 로 “수정”하는 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 가 있는지 사전 확인:
GET /<capture_index_name>/_mapping/field/analysis_state
단기 개선 (1주 이내)#
Cupix::Cron::Searchable.partial_reindex가update_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 으로 실패하는 빈도를 추적:
service:cupixvista-api-worker status:error @class:Capture @function:update_mapping!
전체 Searchable cron 의 모델별 매핑 실패(같은 패턴 다른 모델):
service:cupixvista-api-worker status:error @function:update_mapping!
cron 자체의 wrapper-level rescue (지금은 swallow 되지만 단기 개선 후 표면화될 메시지):
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 는 운영 자동화/스크립트 단의 일회성 절차.