MigrationImportOperation missing admin user validation
RCA: Receiver does not exists: migration id(1855)
Overview#
What Happened#
2026-07-10 13:55 KST부터 cupixworks-migration-worker의 ImportWorker가 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#
- 2026-07-10 13:55 KST —
ImportWorker#perform가 migration id 1855 (copy type)에 대해 첫 실행.MigrationImportOperation.new안get_receiver에서StandardErrorraise → sidekiq 재시도 시작. - 2026-07-10 13:57 ~ 14:12 KST — 동일 워커가 exponential backoff 로 반복 재시도. 매 시도마다 동일 지점에서 실패 (info
Database import(copy) begin, errorImport model failed ... last step(initialize)반복). - 2026-07-10 14:13 KST —
sidekiq_retries_exhausted훅 발화 →Database import retries exhausted: migration id(1855) - StandardError: Receiver does not exists로그 후MigrationOperation.check_import(..., result: 'error')로 migration 종료.
Error Log#
Receiver does not exists: migration id(1855)
전체 스택 (sibling cluster bd7697de-82c6-4615-ac6f-c5802c1f0e63의 대표 에러):
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:18—ImportWorker#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:530—raise StandardError, 'Receiver does not exists' - 예외 처리:
app/workers/import_worker.rb:227-242(rescue StandardError→raise e→ sidekiq 재시도) - 재시도 소진:
app/workers/import_worker.rb:10-16(sidekiq_retries_exhausted블록에서check_import(..., result: 'error'))
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 를 즉시 호출한다.
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:
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 쿼리:
service:cupixworks-migration-worker "migration id(1855)"
핵심 로그 시퀀스 (반복 재시도 확인, KST 로 표기):
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 지양 —
initialize안get_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발생 건수:
sum:trace.sidekiq.job.errors{service:cupixworks-migration-worker,resource_name:ImportWorker}.as_count()
- 원문 메시지 기반 로그 카운트 (log-based metric 이 이미 있으면 그것을 사용, 없다면 아래
logs()쿼리):
logs("service:cupixworks-migration-worker \"Receiver does not exists\"").index("*").rollup("count").by("host").as_count()
sidekiq_retries_exhausted발생:
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 리팩터를 함께 하면 약간 커지지만 이 파일에 국한된다.