ES /docs

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#

  1. 2026-07-01 15:42 KST — 첫 "Bulk operation failed" 로그 (first_seen)
  2. 2026-07-01 15:56 KST — 동일 request_id: f5d78def3566ffc6501ef79a로 EditingEntity/Pointcloud/Capture 반복 발생 (ids_count: 0)
  3. 2026-07-01 15:57 KSTDatabase import success: migration id(1685) — migration 자체는 성공 종료
  4. 2026-07-01 15:55 KST — 마지막 error 로그 (last_seen)

Error Log#

Datadog Logs

text
Bulk operation failed

대표 로그 원문:

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"
}

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가 자연스럽게 비게 된다:

  1. update_capture_by_editing_entity@changed_key[:capture_id]가 nil/empty이거나 모두 next로 스킵된 경우 (로그 원문: update capture for capture ids([]) using editing ids([])).
  2. 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:209import_operation.update_capture_by_editing_entity
  • 그 밖에 migrate_model 경로에서도 동일 헬퍼 사용 (app/operations/migration_import_operation.rb:1179)
  • 헬퍼: app/operations/migration_import_operation.rb:1062-1064bulk_search
  • Raise site: app/models/concerns/searchable.rb:195Cupix::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에 전달된다:

app/operations/migration_import_operation.rb:423-449ruby
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가 없다:

app/operations/migration_import_operation.rb:1062-1064ruby
def bulk_search(model, ids)
  model.try(:bulk_operation, ids, 'index')
end

migrate_model의 마지막 라인도 동일한 경로를 밟는다 — 재migrate 스킵된 배치에서 inserted_ids == []가 되는 케이스:

app/operations/migration_import_operation.rb:1122-1179ruby
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하는 지점:

app/models/concerns/searchable.rb:170-195ruby
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 쿼리:

text
service:cupixworks-migration-worker "Bulk operation failed"
text
service:cupixworks-migration-worker @request_id:f5d78def3566ffc6501ef79a

동일 request_id 컨텍스트에서 확인된 결정적 로그 — updated_capture_ids가 실제로 빈 배열임을 명시:

text
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의 최종 상태:

text
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):

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_classCupix::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 문법):

text
sum:datadog.logs.ingested{service:cupixworks-migration-worker,status:error} by {environment}.as_count()
text
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 추가로 대부분 해소)