Bulk operation failed
RCA: Bulk operation failed (ids is required)
Overview#
What Happened#
Migration import worker(ImportWorker / MigrationImportOperation)가 QA 환경에서 마이그레이션 처리 중 Searchable.bulk_operation을 빈 ids 배열로 호출하여 Cupix::Errors::Argument "ids is required" 예외가 발생했다. 예외는 bulk_operation의 rescue 블록이 삼키지만, 매 호출마다 error 레벨 로그가 남아 15분 동안 119건이 쌓였다. Migration 자체는 성공(Database import success: migration id(1685)) — Elasticsearch reindex 단계에서 no-op을 error 로그로 기록한 것이 원인.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Cupix::Errors::Argument |
| exception.message | ids is required |
| top_frame | app/models/concerns/searchable.rb:195 (raise site) |
| caller | app/operations/migration_import_operation.rb:1063 (bulk_search) |
| runtime | Rails / Sidekiq (cupixworks-migration-worker role) |
| deploy | qa-us-west-2-20260630T0625Z0-5720d493-cupixworks |
| env | qa, us-west-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
Migration Import (ImportWorker) |
119 | 사용자 영향 없음 — migration id(1685)는 성공했고 예외는 내부에서 rescue됨. 로그 노이즈만 발생. |
Timeline#
- 2026-07-01 15:42 KST — 첫 "Bulk operation failed" 로그 (first_seen)
- 2026-07-01 15:56 KST — 동일
request_id: f5d78def3566ffc6501ef79a로 EditingEntity/Pointcloud/Capture 반복 발생 (ids_count: 0) - 2026-07-01 15:57 KST —
Database import success: migration id(1685)— migration 자체는 성공 종료 - 2026-07-01 15:55 KST — 마지막 error 로그 (last_seen)
Error Log#
Bulk operation failed
대표 로그 원문:
{
"message": "Bulk operation failed",
"class": "Capture",
"function": "bulk_operation",
"operation": "index",
"ids_count": 0,
"error_class": "Cupix::Errors::Argument",
"error_message": "ids is required",
"request_id": "f5d78def3566ffc6501ef79a",
"environment": "qa",
"service_role": "migrationworker"
}
Impact#
- Service:
cupixworks-migration-worker - 발생 횟수: 119
- 최초 발생: 2026-07-01 15:42 KST
- 최근 발생: 2026-07-01 15:55 KST
기능적 사용자 영향 없음. Migration import worker의 rescue 블록이 예외를 삼키고 false를 반환하므로 마이그레이션은 성공적으로 완료되었다. 다만 error 레벨 로그가 15분 동안 119건 발생하여 실제 심각한 에러를 가리는 신호-대-잡음 저하 위험이 있다.
Root Cause Summary#
MigrationImportOperation에서 Elasticsearch reindex를 위해 호출하는 헬퍼 bulk_search(model, ids)가 ids의 빈 배열 가드 없이 model.bulk_operation(ids, 'index')를 호출한다. Searchable.bulk_operation!은 방어적으로 ids.blank? 시 Cupix::Errors::Argument(code: 'ARG10000', reason: 'ids is required')를 raise하도록 되어 있어, 호출자가 빈 배열을 넘기면 예외가 터진다. Migration import 흐름에서 다음 조건에서 ids가 자연스럽게 비게 된다:
update_capture_by_editing_entity—@changed_key[:capture_id]가 nil/empty이거나 모두next로 스킵된 경우 (로그 원문:update capture for capture ids([]) using editing ids([])).migrate_model— 대상 model의datas가 비어있거나, 모두inserted?가드로 재migrate 스킵된 경우inserted_ids가[]로 남아bulk_search(model, [])호출 (EditingEntity/Pointcloud 로그 설명).
즉, "빈 컬렉션 = no-op"이어야 할 정상 시나리오가 방어적 raise와 만나 error 로그로 승격되었다.
Technical Analysis#
Code Path#
- Entry:
app/workers/import_worker.rb:209—import_operation.update_capture_by_editing_entity - 그 밖에
migrate_model경로에서도 동일 헬퍼 사용 (app/operations/migration_import_operation.rb:1179) - 헬퍼:
app/operations/migration_import_operation.rb:1062-1064—bulk_search - Raise site:
app/models/concerns/searchable.rb:195—Cupix::Errors::Argument - Rescue site:
app/models/concerns/searchable.rb:172-181— StandardError를 잡아 error 로그 후false반환
update_capture_by_editing_entity는 변경된 capture_id 매핑을 순회하며 조건에 맞는 것만 updated_capture_ids에 누적한다. 모든 항목이 next로 걸러지면 빈 배열이 그대로 bulk_search에 전달된다:
def update_capture_by_editing_entity
Cupix::Logger.info("Database import - migration id(#{@migration_id}): begin update_capture_by_editing_entity", class: self.class.name, function: __method__)
updated_capture_ids = []
editing_ids = []
(@changed_key[:capture_id] || []).each do |changed|
capture_id = changed[:to]
next if (capture_id == changed[:from]) || capture_id.nil?
editing_entity = EditingEntity.find_by(entity_type: 'Capture', entity_id: capture_id)
next if editing_entity.nil?
next if editing_entity.editing_id.nil?
new_editing_id = (@changed_key[:editing_id] || []).find { |c| c[:from] == editing_entity.editing_id }&.dig(:to)
Capture.where(id: capture_id).update_all(editing_id: new_editing_id)
Cupix::Logger.info("Database import - migration id(#{@migration_id}): updated capture editing_id", class: self.class.name, function: __method__, capture_id: capture_id, editing_id: new_editing_id)
editing_ids << new_editing_id
updated_capture_ids << capture_id
end
Cupix::Logger.info("Database import - migration id(#{@migration_id}): update capture for capture ids(#{updated_capture_ids}) using editing ids(#{editing_ids})", class: self.class.name, function: __method__)
bulk_search(Capture, updated_capture_ids) # <-- 빈 배열이면 여기서 예외
end
bulk_search 자체에는 empty-guard가 없다:
def bulk_search(model, ids)
model.try(:bulk_operation, ids, 'index')
end
migrate_model의 마지막 라인도 동일한 경로를 밟는다 — 재migrate 스킵된 배치에서 inserted_ids == []가 되는 케이스:
inserted_ids = []
datas.each do |data|
# ... inserted? 가드로 이미 migrate된 항목은 next 되고
# 매핑이 없으면 inserted_ids 에도 추가되지 않음
if inserted?(foreign_id: model_id, from: from_id)
existing_mapping = @changed_key[model_id]&.find { |c| c[:from] == from_id }
inserted_ids << existing_mapping[:to] if existing_mapping
next
end
inserted = insert_model!(model, _data)
inserted_ids << inserted.id
# ...
end
bulk_search(model, inserted_ids) # inserted_ids 가 [] 이면 raise
Searchable.bulk_operation!이 raise하는 지점:
def bulk_operation(ids, operation = 'index', refresh_cached = false)
bulk_operation!(ids, operation, refresh_cached)
rescue StandardError => e
Cupix::Logger.error('Bulk operation failed',
class: self.name, function: __method__,
operation: operation,
ids_count: ids&.size,
# ...
error_class: e.class.name,
error_message: e.message)
false
else
true
end
def bulk_operation!(ids, operation = 'index', refresh_cached = false)
raise Cupix::Errors::Argument.new(code: 'ARG10000', reason: 'ids is required') if ids.blank?
# ...
end
기대 동작 vs 실제 동작: 정상 흐름에서 "이번 배치엔 reindex할 문서가 없다"는 것은 예외가 아닌 no-op이어야 한다. 실제로는 raise → rescue → error 레벨 로그 → false 반환의 경로를 밟아, 성공한 migration에도 error 로그가 발생한다.
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-migration-worker "Bulk operation failed"
service:cupixworks-migration-worker @request_id:f5d78def3566ffc6501ef79a
동일 request_id 컨텍스트에서 확인된 결정적 로그 — updated_capture_ids가 실제로 빈 배열임을 명시:
2026-07-01 15:57:27 INFO Database import - migration id(1685): begin update_capture_by_editing_entity
2026-07-01 15:57:27 INFO Database import - migration id(1685): update capture for capture ids([]) using editing ids([])
2026-07-01 15:57:27 ERROR Bulk operation failed (class=Capture, ids_count=0, error_message="ids is required")
그리고 같은 request_id의 최종 상태:
2026-07-01 15:57:27 WARN Database import finish: migration id(1685) last step(migrate_pano) - result(success)
2026-07-01 15:57:27 INFO Database import success: migration id(1685)
에러 이벤트 페이로드 (raw JSON):
{
"message": "Bulk operation failed",
"class": "Capture",
"function": "bulk_operation",
"operation": "index",
"ids_count": 0,
"error_class": "Cupix::Errors::Argument",
"error_message": "ids is required",
"request_id": "f5d78def3566ffc6501ef79a",
"environment": "qa",
"service_role": "migrationworker",
"dd": { "service": "cupixworks-api", "env": "qa" }
}
EditingEntity/Pointcloud 케이스도 동일 error_class/error_message/ids_count: 0로 관찰됨 (동일 request_id).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | MigrationImportOperation#bulk_search(및 update_capture_by_editing_entity)가 empty-guard 없이 Searchable.bulk_operation(ids=[])를 호출하여 Cupix::Errors::Argument("ids is required")가 rescue되며 error 로그를 남긴다 |
(1) Datadog 페이로드에 error_class=Cupix::Errors::Argument, error_message="ids is required", ids_count=0 확인. (2) 동일 request_id에 update capture for capture ids([]) using editing ids([]) info 로그가 error 직전에 존재. (3) searchable.rb:195에서 raise , migration_import_operation.rb:1063에서 empty-guard 없이 호출. |
— | Confirmed |
| H2 | Elasticsearch/Faraday timeout 등 인프라 문제 | Elasticsearch나 Faraday timeout이면 error_class가 Faraday::TimeoutError 또는 Elasticsearch::Transport::*여야 함 |
실제 로그의 error_class는 Cupix::Errors::Argument이고 ES 호출 이전 단계에서 실패 |
Rejected |
| H3 | 특정 배포로 인해 유입된 새 코드 결함 (신규 회귀) | 배포 버전 qa-us-west-2-20260630T0625Z0-5720d493 확인됨 |
bulk_search 헬퍼와 bulk_operation!의 ids.blank? 가드는 기존 코드 패턴이며, 이번 증상은 신규 회귀가 아니라 특정 migration input(빈 change set)에서 노출되는 잠재 버그. git log로 최근 변경 확인 필요 — needs verification |
Inconclusive |
| H4 | 클러스터 자체가 production 이슈 (cluster frontmatter의 tenant/region 기준) | 클러스터 파일 tenant=cupix, region=us-west-2 | Datadog 로그의 environment: qa, dd.env: qa 태그가 QA 환경임을 명시. Production trigger 여부는 별도 검증 필요 — needs verification (동일 코드가 prod에서도 동작하므로 재현 가능성 있음) |
Inconclusive |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
app/operations/migration_import_operation.rb:1062-1064 - 방향:
bulk_search헬퍼에서ids.blank?이면 조기 return하여 no-op 처리. Elasticsearch reindex 대상이 0건인 것은 마이그레이션 성공 시나리오이며 error 로그가 발생해서는 안 됨. - 파일:
app/operations/migration_import_operation.rb:447-448그리고:1179— 호출부에서도 방어적으로updated_capture_ids.present?/inserted_ids.present?가드를 두면 로그 노이즈도 줄어든다.
단기 개선 (1주 이내)#
Searchable.bulk_operation(rescue 경로) 재검토:ids.blank?로 인한Cupix::Errors::Argument는 rescue 시 error 로그가 아닌 debug/info 로그로 강등하거나,bulk_operation!이 raise하기 전에 caller에서 걸러내는 규약을 문서화한다.bulk_operation!의raise Cupix::Errors::Argument(... 'ids is required')가 실제로 방어할 상위 오류(예: 호출자의 프로그래밍 실수)와 정상 no-op(빈 배치)을 구분해야 한다. 하나의 예외 타입으로 두 케이스를 처리하는 것이 신호-대-잡음 저하 원인.- Repository 계층의 다른
bulk_operation호출부는 이미present?가드가 있음(예:app/repositories/concerns/bulk_repository/pano.rb:40,form_design_repository.rb:195-197) — migration 계층에도 동일 컨벤션 적용.
장기 개선 (재발 방지)#
- "빈 배치는 no-op" 규약을 Searchable concern 문서/RuboCop custom cop으로 강제.
- Migration import 전용 spec에 "빈 change set" 시나리오(모두 이미 migrate된 재실행) 회귀 테스트 추가 —
spec/operations/migration_import_operation_spec.rb.
Monitoring#
Release dashboard에 넣을 시계열 위젯 (widget-safe timeseries 문법):
sum:datadog.logs.ingested{service:cupixworks-migration-worker,status:error} by {environment}.as_count()
sum:datadog.logs.ingested{service:cupixworks-migration-worker,@error_class:Cupix::Errors::Argument,@error_message:"ids is required"} by {environment}.as_count()
Alert 신호가 필요하면 별도 monitor로 정의(위 timeseries와 별도) — widget 임베드용 쿼리에는 > threshold 같은 monitor-only 접미사를 넣지 않는다.
Risk Assessment#
- Risk level: low (사용자 영향 없음, 성공한 migration에서 발생하는 에러 로그 노이즈)
- 예상 복잡도: trivial (
bulk_search에 3줄짜리 empty-guard 추가로 대부분 해소)