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#
- 2026-07-10 14:04 KST — 최초
Receiver does not exists: migration id(1855)로그 발생 (first_seen) - 2026-07-10 14:04 ~ 14:13 KST —
ImportWorker가 Sidekiq 재시도를 반복하며 총 48회의Receiver does not exists관련 로그 발생 (peer cluster8c3c459c-dd80-4406-9159-51ae5c649288) - 2026-07-10 14:12:19 KST — 첫 번째
sidekiq_retries_exhausted발화 (5회 재시도 소진) - 2026-07-10 14:13:23 KST — 마지막
sidekiq_retries_exhausted발화 (last_seen) — 4회에 걸쳐 동일 migration_id 1855 로 재큐잉된 것으로 추정
Error Log#
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 로 기록되고, 후속 MigrationReprocessWorker 나 FlushRecordGeoCoordinateWorker 도 스케줄되지 않았다. 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:60—ImportWorker.perform_async(data.to_json)로 Sidekiq 큐잉 (destination 검증 없음) - Worker execution:
app/workers/import_worker.rb:50—MigrationImportOperation.new(...)호출 - Constructor:
app/operations/migration_import_operation.rb:26—@receiver = get_receiver - Failure point:
app/operations/migration_import_operation.rb:530—raise StandardError, 'Receiver does not exists' - Retry exhaustion:
app/workers/import_worker.rb:10-16—sidekiq_retries_exhausted훅이Database import retries exhausted: ...를 로깅하고 migration 을error상태로 마킹
Sidekiq 옵션은 retry: 5 (import_worker.rb:3-4):
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 등)도 실행되지 않는다:
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:
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 재시도가 발생한다:
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:
service:cupixworks-migration-worker "migration id(1855)"
핵심 로그 (KST 로 변환된 timestamp):
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_step이initialize로 남는다). - 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_asyncjob 이 큐잉되었음을 의미한다. 운영자 혹은MigrationsController#importre-invocation 에 의해 총 4회 재시도되었을 가능성이 높다 (uncertain — needs verification viaMigrationsController#importaudit log orMigrationOperation.check_importhistory).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Destination team 에 Administrators 그룹 소속 user 가 존재하지 않아 get_receiver 가 raise |
Stack trace migration_import_operation.rb:530 → raise 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.find 는 migration_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:227의rescue 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-63의import액션(또는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 쿼리 예시:
service:cupixworks-migration-worker status:error @message:"Database import retries exhausted"
service:cupixworks-migration-worker status:error @message:"Receiver does not exists"
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 회귀 테스트 필요.