ES /docs

MigrationImportOperation missing admin user validation

RCA: Receiver does not exists: migration id(1855)

Overview#

What Happened#

2026-07-10 13:55 KST부터 cupixworks-migration-workerImportWorker가 migration id 1855 (copy type)를 처리하면서 24회 연속 실패했다. 실패 지점은 MigrationImportOperation#get_receiver — 대상(@to) 팀에서 'Administrators' 그룹 소속 사용자를 찾지 못해 StandardError, 'Receiver does not exists'를 raise 한다. Sidekiq 재시도가 소진되어 (sidekiq_retries_exhausted) migration 상태가 error로 마감되었다.

Quick Facts#

Field Value
exception.class StandardError
exception.message Receiver does not exists
top_frame app/operations/migration_import_operation.rb:530
runtime ruby 3.3.0, sidekiq 7.3.9
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
migration id 1855 대상 팀 (team id 로그 미포함) 24 단일 migration 요청이 완료되지 못하고 최종 error 상태로 마감. 다른 migration 은 영향 없음.

Timeline#

  1. 2026-07-10 13:55 KSTImportWorker#perform가 migration id 1855 (copy type)에 대해 첫 실행. MigrationImportOperation.newget_receiver 에서 StandardError raise → sidekiq 재시도 시작.
  2. 2026-07-10 13:57 ~ 14:12 KST — 동일 워커가 exponential backoff 로 반복 재시도. 매 시도마다 동일 지점에서 실패 (info Database import(copy) begin, error Import model failed ... last step(initialize) 반복).
  3. 2026-07-10 14:13 KSTsidekiq_retries_exhausted 훅 발화 → Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists 로그 후 MigrationOperation.check_import(..., result: 'error') 로 migration 종료.

Error Log#

Datadog Logs

text
Receiver does not exists: migration id(1855)

전체 스택 (sibling cluster bd7697de-82c6-4615-ac6f-c5802c1f0e63의 대표 에러):

Import model failed stacktext
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'

Impact#

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

동일 root cause 로 status-board 가 3개 cluster 를 하나의 incident 로 묶었다 (2026-07-10-svc-cupixworks-migration-worker--unknown-1):

  • 8c3c459c-... — 원본 error log ("Receiver does not exists: migration id(1855)"), 24회
  • bd7697de-...Import model failed ... wrapper log, 24회
  • d4d04d3a-...Database import retries exhausted ... (재시도 소진), 4회

이 세 cluster 는 모두 같은 migration 1855 실패의 서로 다른 로그 라인이다.

Root Cause Summary#

MigrationImportOperation#get_receiver 는 migration 대상(@to) 모델의 team 에서 이름이 'Administrators' 인 group 에 속한 user 중 created_at 이 가장 이른 사용자를 receiver 로 선택한다. migration 1855 의 대상 team 은 해당 group 에 속한 user 가 하나도 없어 receiver_candidates 가 빈 배열이 되고, receiver.blank? 브랜치에서 StandardError 가 raise 된다. 이 예외는 MigrationImportOperation 생성자(initialize) 안에서 발생하므로 어떤 재시도로도 대상 team 에 관리자 user 가 추가되지 않는 한 자기치유가 불가능한 상태이며, Sidekiq 은 MAX_RETRY_COUNT = 5 만큼 backoff 재시도를 반복한 뒤 소진된다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/import_worker.rb:18ImportWorker#perform(arg) 진입
  • Import operation 생성: app/workers/import_worker.rb:50
  • Constructor 내 receiver 조회: app/operations/migration_import_operation.rb:26 (@receiver = get_receiver)
  • Failure point: app/operations/migration_import_operation.rb:530raise StandardError, 'Receiver does not exists'
  • 예외 처리: app/workers/import_worker.rb:227-242 (rescue StandardErrorraise e → sidekiq 재시도)
  • 재시도 소진: app/workers/import_worker.rb:10-16 (sidekiq_retries_exhausted 블록에서 check_import(..., result: 'error'))
app/workers/import_worker.rb:50-58ruby
    import_operation = MigrationImportOperation.new(
      migration_id: migration_id,
      type: type,
      source: source,
      to: to,
      export_download_url: export_download_url,
      import_upload_url: import_upload_url,
      changed_key: changed_key_cache
    )

MigrationImportOperation.new 는 아래 constructor 에서 get_receiver 를 즉시 호출한다.

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
    @auth_id = {
      team_id: @receiver[:team_id],
      user_id: @receiver[:user_id]
    }
    ...
  end

실패가 발생하는 get_receiver:

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
    ...
  end

'Administrators' group 은 TeamFactory 에서 팀 생성 시 만들어진다 (app/factories/team_factory.rb:34-36), 그러나 group 이 만들어진다고 해서 그 group 에 반드시 user 가 속하지는 않는다. 관리자 사용자가 팀에서 제거되거나 처음부터 배정되지 않은 팀은 이 조건에서 빈 결과를 반환한다.

기대 동작 vs 실제 동작

  • 기대: 대상 team 에 관리자 사용자가 없을 때, migration 을 sidekiq 재시도 대상이 아닌 명확한 사용자 오류로 마감하고 요청자에게 "대상 팀에 관리자가 없다"는 사유를 전달해야 한다.
  • 실제: 일반 StandardError 로 raise → sidekiq 재시도 정책(retry: 5)이 적용되어 backoff 총 6번 시도. 매 시도마다 동일 지점 (constructor) 에서 실패 → 3개 로그 라인(원본 error, wrapper error, retries_exhausted) 이 각각 폭발적으로 쌓인다.

또한 ensure 블록의 import_operation&.delete_data_file 은 constructor 에서 예외가 raise 되었기 때문에 import_operation 이 nil 이며 아무 정리도 수행되지 않는다 — 이번 케이스에서는 큰 부작용은 없지만(다운로드/파일 생성 전에 실패했으므로), constructor 안에서 side-effect 가 있는 초기화를 하는 패턴은 리스크로 남는다.

Log Evidence#

Datadog 쿼리:

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

핵심 로그 시퀀스 (반복 재시도 확인, KST 로 표기):

ImportWorker retry loop for migration 1855text
2026-07-10 13:55:57 KST  info   Database import(copy) begin -  migration id(1855) retry_limit(5)   class=ImportWorker
2026-07-10 13:55:57 KST  error  Receiver does not exists: migration id(1855)                       class=MigrationImportOperation method=get_receiver
2026-07-10 13:55:57 KST  error  Import model failed: migration id(1855) last step(initialize) - StandardError: Receiver does not exists  class=ImportWorker
2026-07-10 13:57:09 KST  info   Database import(copy) begin -  migration id(1855) retry_limit(5)
2026-07-10 13:57:43 KST  info   Database import(copy) begin -  migration id(1855) retry_limit(5)
...
2026-07-10 14:12:19 KST  info   Database import(copy) begin -  migration id(1855) retry_limit(5)
2026-07-10 14:13:23 KST  info   Database import(copy) begin -  migration id(1855) retry_limit(5)
2026-07-10 14:13:23 KST  error  Import model failed: migration id(1855) last step(initialize) - StandardError: Receiver does not exists
2026-07-10 14:13:23 KST  error  Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists  class=ImportWorker

sidekiq_retries_exhausted 는 최종 1회만 나타나며 (d4d04d3a-... cluster occurrence_count 4 는 여러 shard/스택 로그 중복으로 추정), 그 시각(14:13:23 KST)에 MigrationOperation.check_import(migration_id: 1855, result: 'error') 가 호출되어 migration 이 마감된다 (app/workers/import_worker.rb:14-15).

Datadog 검색상 대상 team_id / team_domain 이 로그로 남지 않아 어떤 팀이었는지는 로그만으로 확정 불가 — DB 에서 migration id 1855 의 to payload 를 조회해야 확인된다 (uncertain -- needs verification).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 대상 team 에 'Administrators' group 소속 user 가 존재하지 않아 receiver_candidates 가 빈 배열이 되고 constructor 에서 raise migration_import_operation.rb:522-530 에서 이 조건에서만 해당 error 메시지가 raise 됨. 24회 재시도가 모두 last step(initialize) 로 실패, 재시도 후에도 상태가 변하지 않음. Confirmed
H2 Migration 대상 model (@to[:model_name]/@to[:model_id]) 자체가 없어서 constantize.find 에서 ActiveRecord::RecordNotFound 가 발생 에러 클래스가 StandardError 이며 메시지가 "Receiver does not exists" 로 일치. ActiveRecord::RecordNotFound 였으면 다른 메시지/클래스가 raise 되었을 것. Rejected
H3 외부 의존성 (DB, S3, redis) 일시 장애 status-board svc:cupixworks-migration-worker::unknown scope, dep:* 매칭 없음. 24회 모두 동일한 논리적 지점에서 실패했고 backoff 중에도 회복되지 않음 → 인프라 flake 성격이 아님. Rejected
H4 Sidekiq 재시도 로직 자체 버그 (같은 job 이 다중 실행) 짧은 시간 다수 로그. Database import(copy) begin 시퀀스가 15-90초 간격의 backoff 로 늘어남 (13:55 → 14:13) — 정상 sidekiq exponential backoff 패턴에 부합. migrationable? 가드가 있어 중복 실행도 억제됨 (import_worker.rb:60-63). Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 대상 team 에 관리자 user 를 추가하여 migration 1855 재시작 — 이번 케이스는 운영 조치로 즉시 해소된다. migration id 1855 의 to.model_name/to.model_id 를 확인 후, 해당 team 의 'Administrators' group 에 최소 1명의 활성 user 를 배정하고 ImportWorker.perform_async(...) 를 재실행하거나 admin controller 의 import 재시작 flow 를 사용한다.
    • 대상 팀 정보 확인 필요 (로그에는 team_id 미기록). DB / migration 테이블 조회로만 확인 가능.

단기 개선 (1주 이내)#

  • get_receiver 실패를 non-retryable 사용자 오류로 분리app/operations/migration_import_operation.rb:518-531.
    • "관리자 없음" 조건은 재시도해도 자기치유되지 않는 영구적 상태이므로, 일반 StandardError 대신 별도의 non-retryable exception 클래스(예: Cupix::Errors::MigrationReceiverMissing)를 raise 하도록 변경.
    • ImportWorker 에서 이 예외를 rescue 하여 재시도 없이 MigrationOperation.check_import(migration_id:, result: 'error') 를 즉시 호출하고 요청자에게 "대상 팀에 관리자 사용자가 없음" 원인을 노출.
    • 5회 backoff 재시도 및 로그 폭증을 예방한다.
  • 로그에 대상 team 컨텍스트 추가Receiver does not exists 로그에 @to[:model_name], @to[:model_id], 조회된 team_id 를 함께 남긴다. 현재는 어느 팀인지 로그만으로 특정할 수 없어 운영 대응이 지연된다.
  • 사전 검증(pre-flight) 도입 검토MigrationOperation.create_migration_request 시점에 대상 team 의 관리자 존재 여부를 검사하고 없으면 4xx 로 즉시 거부. worker 진입 전에 사용자에게 오류를 전달.

장기 개선 (재발 방지)#

  • Constructor 에서 side-effect / raise 지양initializeget_receiver, get_default_id, Facility.find 등이 실패하면 import_operation 자체가 nil 이 되어 ensure 블록의 정리 로직 (import_operation&.delete_data_file) 이 스킵된다. 이러한 검증/조회는 별도의 prepare! 단계로 분리하고, 초기화는 순수 파라미터 대입만 담당하도록 리팩터.
  • Migration receiver 선정 정책 재검토 — 관리자 그룹 최고 오래된 user 라는 hard-coded 정책 대신, migration 요청 시점에 명시적으로 receiver user 를 선택 하거나, 대상 workspace/team 의 owner/service account 를 fallback 으로 사용하는 방안 검토.

Monitoring#

Datadog release dashboard timeseries widget 용 쿼리 (widget 에 그대로 임베드 가능한 문법):

  • Receiver does not exists 발생 건수:
text
sum:trace.sidekiq.job.errors{service:cupixworks-migration-worker,resource_name:ImportWorker}.as_count()
  • 원문 메시지 기반 로그 카운트 (log-based metric 이 이미 있으면 그것을 사용, 없다면 아래 logs() 쿼리):
text
logs("service:cupixworks-migration-worker \"Receiver does not exists\"").index("*").rollup("count").by("host").as_count()
  • sidekiq_retries_exhausted 발생:
text
logs("service:cupixworks-migration-worker \"Database import retries exhausted\"").index("*").rollup("count").as_count()

알림:

  • Receiver does not exists 가 5분 창에서 3회 이상 발생하면 warn — 하나의 migration 이 재시도 loop 에 빠졌다는 지표.
  • Database import retries exhausted 가 발생하면 즉시 alert — migration 이 사용자 개입 없이 실패로 마감된 상태.

Risk Assessment#

  • Risk level: medium — 단일 migration 실패이며 다른 migration/사용자에 확산되지 않지만, 재시도 loop 로 인해 로그/워커 리소스가 반복 낭비되고 사용자에게 원인 메시지가 명확히 전달되지 않는다.
  • 예상 복잡도: standard — non-retryable 예외 분리와 pre-flight 검증은 국소적 변경. Constructor 리팩터를 함께 하면 약간 커지지만 이 파일에 국한된다.