ES /docs

Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists

RCA: Database import retries exhausted (migration 1855) — Receiver does not exists

Overview#

What Happened#

2026-07-10 14:04 KST부터 14:13 KST까지 약 9분 사이에 production us-west-2 의 cupixworks-migration-worker 에서 migration id 1855 에 대해 ImportWorker 가 5회의 Sidekiq 재시도를 모두 소진하고 sidekiq_retries_exhausted 훅을 4번 발화시켰다. 실패 원인은 매번 동일하다. MigrationImportOperation 초기화 도중 get_receiver 가 destination team 에서 Administrators 그룹에 속한 사용자를 찾지 못해 StandardError: Receiver does not exists 를 raise 한 뒤, 첫 스텝(initialize) 조차 진입하지 못하고 종료된다.

Quick Facts#

Field Value
exception.class StandardError
exception.message Receiver does not exists
top_frame app/operations/migration_import_operation.rb:530
deploy /var/app/current (Elastic Beanstalk 배포 경로)
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
migration id 1855 destination team 4 (retries_exhausted) Database import 전체 실패 — 어떤 facility/level/record 도 destination 에 반영되지 못함

Timeline#

  1. 2026-07-10 14:04 KST — 최초 Receiver does not exists: migration id(1855) 로그 발생 (first_seen)
  2. 2026-07-10 14:04 ~ 14:13 KSTImportWorker 가 Sidekiq 재시도를 반복하며 총 48회의 Receiver does not exists 관련 로그 발생 (peer cluster 8c3c459c-dd80-4406-9159-51ae5c649288)
  3. 2026-07-10 14:12:19 KST — 첫 번째 sidekiq_retries_exhausted 발화 (5회 재시도 소진)
  4. 2026-07-10 14:13:23 KST — 마지막 sidekiq_retries_exhausted 발화 (last_seen) — 4회에 걸쳐 동일 migration_id 1855 로 재큐잉된 것으로 추정

Error Log#

Datadog Logs

text
Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists

Impact#

  • Service: cupixworks-migration-worker
  • 발생 횟수: 4
  • 최초 발생: 2026-07-10 14:04 KST
  • 최근 발생: 2026-07-10 14:13 KST

Database migration 1855 는 destination facility/workspace 로 데이터를 옮기지 못한 채 완전히 실패했다. MigrationOperation.check_import(result: 'error') 가 호출되어 migration 상태가 error 로 기록되고, 후속 MigrationReprocessWorkerFlushRecordGeoCoordinateWorker 도 스케줄되지 않았다. Migration 을 다시 시도하려면 destination team 에 admin user 를 추가하고 migration 을 재실행해야 한다.

Root Cause Summary#

MigrationImportOperation#initialize 는 constructor 안에서 @receiver = get_receiver 를 호출하고, get_receiver 는 destination @to[:model_id] (facility 또는 workspace) 소속 team 의 사용자 중 Administrators 그룹에 속한 최고참(min created_at) 사용자를 receiver 로 선택한다. 대상 team 에 Administrators 그룹 사용자가 한 명도 없으면 raise StandardError, 'Receiver does not exists' 로 즉시 실패한다. 이 조건은 (1) MigrationsController#import 요청 시점에 API 레이어에서 사전 검증되지 않고 (2) ImportWorker.perform 진입 후 MigrationImportOperation.new 에서만 감지되기 때문에, Sidekiq 재시도 5회를 모두 소진할 때까지 동일 원인으로 반복 실패한다. Migration 1855 의 destination team 은 admin user 가 비어있는 상태였다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/admin/migrations_controller.rb:60ImportWorker.perform_async(data.to_json) 로 Sidekiq 큐잉 (destination 검증 없음)
  • Worker execution: app/workers/import_worker.rb:50MigrationImportOperation.new(...) 호출
  • Constructor: app/operations/migration_import_operation.rb:26@receiver = get_receiver
  • Failure point: app/operations/migration_import_operation.rb:530raise StandardError, 'Receiver does not exists'
  • Retry exhaustion: app/workers/import_worker.rb:10-16sidekiq_retries_exhausted 훅이 Database import retries exhausted: ... 를 로깅하고 migration 을 error 상태로 마킹

Sidekiq 옵션은 retry: 5 (import_worker.rb:3-4):

app/workers/import_worker.rb:1-16ruby
class ImportWorker
  include Sidekiq::Worker
  MAX_RETRY_COUNT = 5
  sidekiq_options queue: :migration, retry: MAX_RETRY_COUNT

  IMPORT_PROCEED_CACHE_KEY = 'import_proceed'.freeze
  IMPORT_SIDEKIQ_STATUS_CACHE_KEY = 'import_sidekiq_status'.freeze
  CHANGED_CACHE_KEY = 'changed_key'.freeze

  sidekiq_retries_exhausted do |job, e|
    data = JSON.parse(job['args'].first).deep_symbolize_keys
    migration_id = data[:migration_id]

    Cupix::Logger.error("Database import retries exhausted: migration id(#{migration_id}) - #{e.class}: #{e.message}", class: name, method: 'sidekiq_retries_exhausted')
    MigrationOperation.check_import(migration_id: migration_id, result: 'error')
  end

실제 실패는 constructor 진입 직후에 발생한다. Constructor 마지막에 @receiver = get_receiver 가 있어 어떤 실제 마이그레이션 스텝(migrate_facility, migrate_level 등)도 실행되지 않는다:

app/operations/migration_import_operation.rb:11-33ruby
def initialize(args)
  @migration_id = args[:migration_id]
  @type = args[:type]
  @source = args[:source]
  @to = args[:to]
  @export_download_url = args[:export_download_url]
  @import_upload_url = args[:import_upload_url]

  filename = "migration_#{@migration_id}_#{@source[:region]}_#{@source[:model_name]}_#{@source[:model_id]}"
  @export_filename = "export_#{filename}"
  @export_dir = File.join(EXPORT_DIR, "export_#{filename}")
  @import_dir = File.join(IMPORT_DIR, "import_#{filename}")

  workspace_id = @to[:model_name] == 'workspace' ? @to[:model_id] : Facility.find(@to[:model_id]).workspace_id
  @default_id = get_default_id(workspace_id)
  @receiver = get_receiver
  ...
end

Receiver 선택 로직 — team 안의 사용자 중 Administrators 그룹 소속을 찾고, 없으면 raise:

app/operations/migration_import_operation.rb:518-531ruby
def get_receiver
  model = @to[:model_name].camelize.constantize.find(@to[:model_id])
  team_id = model.team_id

  receiver_candidates = Team.find(team_id).users.select do |user|
    user.groups.any? { |group| group.name == 'Administrators' }
  end

  receiver = receiver_candidates.min_by(&:created_at)

  if receiver.blank?
    Cupix::Logger.error("Receiver does not exists: migration id(#{@migration_id})", class: self.class.name, method: __method__)
    raise StandardError, 'Receiver does not exists'
  end

ImportWorker#perform 의 rescue 블록은 이 예외를 잡아 로깅 후 그대로 re-raise 하기 때문에 Sidekiq 재시도가 발생한다:

app/workers/import_worker.rb:227-242ruby
rescue StandardError, Sidekiq::Shutdown => e
  # See app/workers/export_worker.rb for why we raise StandardError instead of Sidekiq::Shutdown.
  Cupix::Logger.error("Import model failed: migration id(#{migration_id}) last step(#{proceed[:last_step]}) - #{e.class}: #{e.message} #{e.backtrace&.join("\n")}", class: self.class.name, method: __method__)
  result = 'error'

  MigrationOperation.check_import(migration_id: migration_id, result: 'retrying')

  MigrationWorker::Util.store_cache(migration_id, IMPORT_SIDEKIQ_STATUS_CACHE_KEY, 'error')
  MigrationWorker::Util.store_cache(migration_id, CHANGED_CACHE_KEY, import_operation.changed_key) if import_operation
  MigrationWorker::Util.store_cache(migration_id, IMPORT_PROCEED_CACHE_KEY, proceed) if proceed

  if e.is_a?(Sidekiq::Shutdown)
    raise StandardError, 'Sidekiq::Shutdown', cause: nil
  end

  raise e

기대 동작: Migration 요청 시점(controller 또는 ImportWorker.perform 진입 직후)에 destination team 의 admin user 존재 여부를 즉시 검증하여 non-retriable error 로 실패시키고, 운영자에게 명확한 원인(“대상 팀에 Administrators 없음”)을 알린다.

실제 동작: 검증 없이 Sidekiq 큐잉 → constructor 에서 raise → rescue 가 재시도 유도 → 6번(초기 + 5회 재시도) 시도 후 exhausted. 조건이 결정적(admin 이 갑자기 생길 확률은 매우 낮음)이므로 재시도가 무의미하다.

Log Evidence#

Datadog query:

text
service:cupixworks-migration-worker "migration id(1855)"

핵심 로그 (KST 로 변환된 timestamp):

text
2026-07-10 14:13:23 error [MigrationImportOperation]
  Receiver does not exists: migration id(1855)

2026-07-10 14:13:23 error [ImportWorker]
  Import model failed: migration id(1855) last step(initialize) - StandardError: Receiver does not exists
    /var/app/current/app/operations/migration_import_operation.rb:530:in `get_receiver'
    /var/app/current/app/operations/migration_import_operation.rb:26:in `initialize'
    /var/app/current/app/workers/import_worker.rb:50:in `new'
    /var/app/current/app/workers/import_worker.rb:50:in `perform'
    /var/app/current/vendor/bundle/ruby/3.3.0/gems/sidekiq-7.3.9/lib/sidekiq/processor.rb:220:in `execute_job'
    ...

2026-07-10 14:13:23 error [ImportWorker]
  Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists

2026-07-10 14:12:19 error [ImportWorker]
  Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists
  • Stack trace 는 migration_import_operation.rb:530 -> :26 -> import_worker.rb:50 순서로, 실패가 MigrationImportOperation.new 시점에 발생함을 확정한다 (즉 last_stepinitialize 로 남는다).
  • Peer cluster 8c3c459c-dd80-4406-9159-51ae5c649288 (48 occurrences, 동일 migration id 1855) 는 rescue-loop 안에서 발생한 Receiver does not exists / Import model failed 로그의 aggregate 이며, 본 cluster 는 그 재시도들이 모두 소진된 후 나오는 exhausted 로그(4회)이다.
  • 4회의 exhausted 이벤트가 관측되는 이유: sidekiq_retries_exhausted 는 job 당 정확히 1회 발화되므로, 동일 migration_id 1855 로 최소 4개의 ImportWorker.perform_async job 이 큐잉되었음을 의미한다. 운영자 혹은 MigrationsController#import re-invocation 에 의해 총 4회 재시도되었을 가능성이 높다 (uncertain — needs verification via MigrationsController#import audit log or MigrationOperation.check_import history).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Destination team 에 Administrators 그룹 소속 user 가 존재하지 않아 get_receiver 가 raise Stack trace migration_import_operation.rb:530raise StandardError, 'Receiver does not exists'; peer cluster 48 회 동일 메시지; 코드상 receiver_candidates.blank? 시 유일한 raise 경로 Confirmed
H2 일시적 DB 커넥션/타임아웃으로 Team.find(team_id).users 조회가 실패 재시도 시 회복 가능성이 이론상 있음 6번의 시도(초기+5재시도) 가 9분 동안 모두 동일 stack trace, 동일 메시지 (Receiver does not exists) 로 실패했고, DB error(ActiveRecord::ConnectionTimeoutError 등) 는 로그에 없음 Rejected
H3 Destination facility/workspace 자체가 존재하지 않아 Facility.find(@to[:model_id])ActiveRecord::RecordNotFound Stack trace 상 실패 원인은 RecordNotFound 가 아닌 StandardError('Receiver does not exists'); Facility.findmigration_import_operation.rb:24 에서 먼저 성공적으로 실행됨 (그렇지 않으면 line 24 에서 실패) Rejected
H4 외부 의존성(Datadog, Redis, Sidekiq) 장애로 인한 실패 status-board 조회 결과 svc:cupixworks-migration-worker::unknown 스코프 내부 서비스 이슈로 분류; 외부 dep 인시던트 없음. Stack trace 도 애플리케이션 예외 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • Migration 1855 destination team 에 admin user 추가 후 재실행 — 데이터 반영이 필요하다면 destination facility (@to[:model_id]) 가 속한 team 의 Administrators 그룹에 최소 1명의 활성 사용자를 배정한 뒤 MigrationsController#import 를 재호출한다. 코드 변경 없이 운영으로 해결 가능한 정정 조치.
  • Non-retriable 예외로 분리app/operations/migration_import_operation.rb:530 에서 raise StandardError 대신 Cupix::Errors::PreconditionFailed(또는 유사한 도메인 예외) 로 raise 하고, app/workers/import_worker.rb:227rescue StandardError 를 (a) 도메인 예외는 즉시 MigrationOperation.check_import(result: 'error') + return 으로 재시도 없이 종료, (b) 그 외 StandardError 는 기존 방식대로 재시도. Sidekiq 자원과 관측 노이즈(peer cluster 48회) 를 함께 줄인다.

단기 개선 (1주 이내)#

  • API 레이어 사전 검증app/controllers/api/v1/admin/migrations_controller.rb:39-63import 액션(또는 validate_migration_params)에서 to.model_id / to.model_name 기준으로 destination team 에 admin user 가 존재하는지 확인하고, 없으면 400 Bad Request (ARG10000 계열) 를 반환한다. Sidekiq 큐잉 전에 실패시키면 재시도·모니터링 노이즈가 원천 차단된다.
  • get_receiver 로직 확장Administrators 그룹 사용자가 없을 때 fallback 후보(예: team owner, group_type_code :administrators) 를 명시적으로 시도하고, 그래도 없으면 명확한 예외 메시지에 team_id, @to[:model_name], @to[:model_id] 를 포함하여 운영자가 어느 team 을 손봐야 하는지 로그만으로 특정할 수 있게 한다.

장기 개선 (재발 방지)#

  • Migration precondition audit — Migration 생성/실행 파이프라인의 destination 검증을 한 곳(MigrationOperation.create_migration_request 또는 별도 MigrationPrecheckService) 으로 통합하고 (admin 존재, storage/workspace 매핑, source ↔ destination region 호환성 등) 실패 시 사용자에게 dashboard 상 명확한 사유를 노출한다.
  • Team factory 사후 검증app/factories/team_factory.rb:35 가 team 생성 시 Administrators 그룹을 자동 생성하는 만큼, 이후 admin user 가 0 인 team 이 발생한 경위(사용자 이탈, 그룹 재배정) 를 감사하는 주기 job 추가 검토.

Monitoring#

  • 알림: Database import retries exhausted 발생 시 Slack/PagerDuty 즉시 통지. 현재는 Datadog error 로그만 있고 대응 지연.
  • Datadog 쿼리 예시:
text
service:cupixworks-migration-worker status:error @message:"Database import retries exhausted"
text
service:cupixworks-migration-worker status:error @message:"Receiver does not exists"
text
service:cupixworks-migration-worker status:error @message:"Import model failed"

Risk Assessment#

  • Risk level: medium — 특정 migration 1건 실패로 서비스 전체 장애는 아니나, 데이터 이관이 완전히 중단되고 운영자 개입 필요.
  • 예상 복잡도: standard — 코드 변경 범위(precondition 검증 + non-retriable 예외 분리) 는 작지만 controller/worker/operation 세 곳에 걸쳐 있고 기존 migration flow 회귀 테스트 필요.