Database import retries exhausted: migration id(1843) - ActiveRecord::RecordNotFound: Couldn't find
RCA: ImportWorker cannot find newly-copied Pano because state=abandoned is excluded by default_scope
Overview#
What Happened#
2026-07-09 07:06 KST 부터 cupixworks-migration-worker 의 ImportWorker 가 migration id 1843 (walmart-tst team, us-west-2, facility 18180 copy) 을 실행하다가 세 번 연속 ActiveRecord::RecordNotFound 로 실패했다. Sidekiq retry 5회를 모두 소진한 뒤 sidekiq_retries_exhausted 훅이 최종 Database import retries exhausted 에러 로그를 남겼다. 실패 지점은 migrate_panos 후반의 migrate_tile_object 로, 방금 insert_model! 로 새로 만든 Pano(id 92228359)를 다시 Pano.find 로 조회할 때 Statable::Pano 의 default_scope { where.not(state: :abandoned) } 에 걸려 조회되지 않았다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | ActiveRecord::RecordNotFound |
| exception.message | Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?] |
| top_frame | app/operations/migration_import_operation.rb:708 |
| runtime | Ruby 3.3.0, ActiveRecord 7.2.2, Sidekiq 7.3.9 |
| env | production, us-west-2 |
| tenant | cupix (team walmart-tst, team_id 1257, user_id 52196) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| walmart-tst (team_id 1257) | 3 | Migration id 1843 (facility 18180 copy) 실패, 사용자에게 error 상태로 노출 |
Timeline#
- 2026-07-09 07:06:05 KST — 첫 시도:
migrate_capture까지 완료하고migrate_pano단계에서 Pano 92228359 조회 실패 - 2026-07-09 07:06:39 KST — Sidekiq retry:
Migration import - migration id(1843): sidekiq is retried - 2026-07-09 07:06:53 KST —
migrate_pano재실행, 동일 Pano id 로 재실패 - 2026-07-09 07:07:13 KST — 재시도 재실패
- 2026-07-09 07:07:45 KST — retry 5 소진,
sidekiq_retries_exhausted훅이Database import retries exhausted로그 기록,MigrationOperation.check_import(result: 'error')로 migration 상태 error 마킹
Error Log#
Database import retries exhausted: migration id(1843) - ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]
Impact#
- Service:
cupixworks-migration-worker - 발생 횟수: 3
- 최초 발생: 2026-07-09 07:06:05 KST
- 최근 발생: 2026-07-09 07:07:45 KST
- 비즈니스 영향: migration id 1843 (walmart-tst 의 facility copy 작업) 이 완료되지 못하고 error 상태로 종료됨. 이미
migrate_pano단계까지 진행된 상태에서 실패했기 때문에, 새 facility 아래에 부분적으로 복사된 record/capture/pano row 들이 DB 에 남아있을 가능성이 높다.
Root Cause Summary#
Statable::Pano 는 default_scope { where.not(state: :abandoned) } 를 걸어 두어, 모든 Pano.find / Pano.where 쿼리에 자동으로 WHERE panos.state != 'abandoned' 절이 붙는다. MigrationImportOperation#migrate_panos 는 원본 facility 의 pano JSON 데이터를 그대로 새 facility 로 복사하는데, insert_model! 이 원본 row 의 state 컬럼을 그대로 삽입하므로 원본이 abandoned 였다면 새로 만든 Pano 도 state='abandoned' 로 저장된다. 이어서 migrate_tile_object 가 새 Pano id 로 Pano.find(new_id) 를 호출하는 순간 default_scope 에 의해 abandoned 인 새 pano 가 결과 집합에서 제외되어 RecordNotFound 가 발생한다. Sidekiq 재시도는 원본 데이터가 그대로이므로 매번 동일 지점에서 실패해 5회 소진 후 최종 에러로 남는다.
Technical Analysis#
Code Path#
- Entry point:
app/workers/import_worker.rb:161(migrate_panostep 진입) - Migration step 실행:
app/workers/import_worker.rb:174(import_operation.migrate_panos(capture_id, pano_data)) - Pano row 삽입 (state 포함):
app/operations/migration_import_operation.rb:315(migrate_model('pano', pano_data, ...)) —insert_model!이 원본data['state']를 그대로 새 row 에 복사 - Failure point:
app/operations/migration_import_operation.rb:708— 방금 만든 Pano 를Pano.find(new_id)로 다시 조회하다 default_scope 에 걸려RecordNotFound
Sidekiq 스택 트레이스에서 확인된 실제 실패 프레임:
/var/app/current/app/operations/migration_import_operation.rb:708:in `block in migrate_tile_object'
/var/app/current/app/operations/migration_import_operation.rb:336:in `migrate_panos'
/var/app/current/app/workers/import_worker.rb:174:in `block (2 levels) in perform'
migrate_panos 의 흐름:
def migrate_panos(capture_org_id, pano_data)
pano_org_ids = []
pano_new_ids = []
ActiveRecord::Base.transaction do
migration = migrate_model('pano', pano_data, { name: 'capture', id: capture_org_id })
pano_org_ids.concat(migration[:org_ids])
pano_new_ids.concat(migration[:new_ids])
pano_org_ids.each do |pano_id|
migrate_model('pano_resource', pano_data, { name: 'pano', id: pano_id })
migrate_model('mask', pano_data, { name: 'pano', id: pano_id })
end
end
# Copy pano original resource for all panos
migrate_resource_object('pano', pano_data, pano_org_ids, pano_new_ids, 100)
all_new_tile = pano_org_ids.present? && pano_org_ids.all? do |org_id|
tile_data = pano_data.dig('pano', org_id.to_s, 'tile')
tile_data.present? && tile_data['tile_size'] == 3
end
if all_new_tile
migrate_tile_object('pano', pano_data, pano_org_ids, pano_new_ids, 10) # ← 실패 지점 진입
migrate_mask_object(pano_data, pano_org_ids, pano_new_ids, 10)
set_pano_states_done(pano_data, pano_org_ids, pano_new_ids)
migrate_tile_object 가 새 Pano 를 다시 조회하는 지점(line 708):
def migrate_tile_object(model_name, data, org_ids, new_ids, batch_size)
return if org_ids.blank?
tile_objects = []
org_ids.each_with_index do |org_id, idx|
new_id = new_ids[idx]
from_tile = data[model_name][org_id.to_s]['tile']
next if from_tile.blank?
next if from_tile['s3_object_keys'].blank? && from_tile['tile_size'].blank?
model_with_tile = model_name.camelize.constantize.find(new_id) # ← line 708: default_scope 로 abandoned 제외
storage_option = model_with_tile.storage_option
insert_model! 은 원본 데이터의 모든 컬럼(state 포함)을 그대로 옮겨 담아 저장한다:
def insert_model!(model, data)
set_identifier_column!(data)
data.delete('model_name')
data.delete('id')
table_columns = model.column_names.map(&:to_s)
data.select! { |key, val| table_columns.include?(key) }
m = model.new
data.each do |key, value|
if value.is_a?(Hash)
m[key] = value.deep_symbolize_keys
else
m[key] = value # state 컬럼도 원본 그대로 복사 (abandoned 포함 가능)
end
end
saved_callbacks = save_callbacks(model)
remove_callbacks(model)
m.try(:set_storage_option, force: true)
m.save
문제의 default_scope:
included do
include ::Statable
default_scope { where.not(state: :abandoned) }
scope :normal, -> { where(pano_type: nil) }
scope :panono, -> { where(pano_type: 'panono') }
scope :tile_fix_candidate, -> { where(state: :done) }
scope :tile_upload_candidate, -> { where(state: %i[resource_uploaded tile_uploading tile_missing]) }
scope :processible, -> { where(state: %i[resource_uploaded done cloning]) }
scope :not_processible, -> { where(state: %i[created resource_missing]) }
scope :complete, -> { where(state: %i[done error]) }
scope :incomplete, -> { where.not(state: %i[done error]).cycle_state_created }
scope :active, -> { where.not(state: [:abandoned]) }
scope :count_candidate, -> { where(cycle_state: :created).where.not(state: %i[error abandoned]) }
기대 동작 vs 실제 동작
- 기대:
migrate_panos는 원본 facility 의 모든 pano row 를 새 facility 로 복사하고, tile_size==3 인 경우migrate_tile_object로 tile 을 그대로 옮긴다. 원본 pano 가 abandoned 상태였다면 tile 을 복사할 필요가 없거나 최소한 조회 실패로 전체 migration 을 중단시키지는 않아야 한다. - 실제: 원본 pano 중 하나(새로 만들어진 id 92228359)의 state 가 abandoned 였고, 이 row 는 새 facility 에 저장까지는 성공했지만
Pano.find(92228359)가 default_scope 때문에RecordNotFound를 던져migrate_panos전체가 중단된다. 재시도 5회 모두 원본 데이터가 바뀌지 않아 같은 지점에서 실패.
Log Evidence#
Datadog Query:
service:cupixworks-migration-worker "migration id(1843)"
Execution timeline:
2026-07-09 07:06:39 KST [warn] Migration import - migration id(1843): sidekiq is retried
2026-07-09 07:06:39 KST [info] Database import(copy) begin - migration id(1843) retry_limit(5)
2026-07-09 07:06:53 KST [info] last step(migrate_capture) / current_step(migrate_pano)
2026-07-09 07:06:53 KST [info] migrate pano(pano) for capture id(663016) / batch id(10)
2026-07-09 07:07:13 KST [error] Import model failed: migration id(1843) last step(migrate_capture) -
ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359
[WHERE `panos`.`state` != ?]
2026-07-09 07:07:13 KST [error] Database import retries exhausted: migration id(1843) -
ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359
[WHERE `panos`.`state` != ?]
Stack trace 핵심 부분:
ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]
/var/app/current/vendor/bundle/ruby/3.3.0/gems/activerecord-7.2.2/lib/active_record/relation/finder_methods.rb:428:in `raise_record_not_found_exception!'
/var/app/current/vendor/bundle/ruby/3.3.0/gems/activerecord-7.2.2/lib/active_record/core.rb:261:in `find'
/var/app/current/app/operations/migration_import_operation.rb:708:in `block in migrate_tile_object'
/var/app/current/app/operations/migration_import_operation.rb:701:in `each_with_index'
/var/app/current/app/operations/migration_import_operation.rb:701:in `migrate_tile_object'
/var/app/current/app/operations/migration_import_operation.rb:336:in `migrate_panos'
/var/app/current/app/workers/import_worker.rb:174:in `block (2 levels) in perform'
핵심 관찰:
WHERE panos.state != ?절이 SQL 에 붙어있음 →Pano모델의 default_scope 가 활성화된 상태에서find가 호출됐다는 증거.- Stack trace 가 실패 위치를 정확히
migration_import_operation.rb:708로 지목. - migration id 1843 은 facility 18180 copy 작업이며
migrate_capture→migrate_pano로 진행 중이었다. - Kibana
panos인덱스에서 id 92228359 는 조회되지 않는다 (total: 0). Search index 는bulk_search로 채워지는데 default_scope 가 걸린Pano스코프를 사용하므로 abandoned row 는 색인되지 않는 것으로 보인다 — DB row 자체는 존재할 가능성이 높다 (SQL 이state != ?를 걸고도 실패한다는 것은 id 는 매칭되지만 state 필터에서 걸러졌다는 뜻).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 원본 pano 의 state='abandoned' 가 그대로 복사되어 새 Pano row 도 abandoned 로 저장됐고, Pano.find 의 default_scope(where.not(state: :abandoned)) 에 걸려 RecordNotFound 발생 |
에러 SQL 이 WHERE panos.state != ? 를 포함 (default_scope 발동 증거). app/models/concerns/statable/pano.rb:8 에서 default_scope { where.not(state: :abandoned) } 정의 확인. insert_model! (migration_import_operation.rb:1043-1049) 이 state 컬럼을 그대로 복사. Stack trace 가 migration_import_operation.rb:708 (find(new_id)) 을 실패 지점으로 지목 |
— | Confirmed |
| H2 | Pano row 가 DB 에 아예 삽입되지 않아서 조회 실패 (transaction rollback 등) | — | migrate_model transaction (line 314-323) 은 pano/pano_resource/mask 삽입만 감싸며 실패 없이 커밋 이후 migrate_tile_object 가 호출됨. 실패 SQL 이 state != ? 필터를 포함한다는 것은 row 는 존재하지만 default_scope 에 걸린다는 뜻 — row 자체가 없으면 SQL 은 id = 92228359 조건 하나만으로도 결과 없음을 반환했을 것 |
Rejected |
| H3 | Sidekiq 재시도 로직의 race condition 으로 이전 시도의 부분 커밋이 다음 재시도를 방해 | 매 재시도마다 동일 위치에서 실패 | 재시도 시 IMPORT_PROCEED_CACHE_KEY 로 진행 상태를 복원하므로 이전 시도의 migrate_capture 는 skip 되고 migrate_pano 부터 재실행 — 매번 동일 원본 데이터로 동일 로직 반복 → race 가 아니라 결정론적 실패. 5회 모두 동일 pano id 92228359 에서 실패한 것도 결정론적 실패의 증거 |
Rejected |
| H4 | External dependency (S3, DB) outage | 매 재시도 실패 | Datadog logs 에 S3/DB 관련 error 없음. status-board svc:cupixworks-migration-worker::unknown scope 에 active incident 없음. 예외 클래스가 순수 ActiveRecord::RecordNotFound |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
옵션 A (권장): 원본이 abandoned 인 pano 는 migration 대상에서 제외
- 파일:
app/operations/migration_import_operation.rb,migrate_panos(line 310-347) 및/또는import_datas의 pano 처리 부분 - 접근:
pano_data['pano']를 순회하기 전에state == 'abandoned'인 항목을 필터링. 사용자가 이미 버린(abandoned) pano 는 새 facility 로 복사할 필요가 없다. - 근거: 비즈니스 관점에서 abandoned 는 "무시된" 상태이며 복사 대상이 아니다. 필터링하면 default_scope 와도 충돌하지 않는다.
옵션 B (보완): find 호출을 unscoped 로 우회
- 파일:
app/operations/migration_import_operation.rb:708,line 755(Pano.find(new_id)),line 793(Pano.find(new_id)) - 접근:
model_with_tile = model_name.camelize.constantize.unscoped.find(new_id)로 default_scope 를 무시하고 조회.set_pano_states_done(line 793) 은 이후 state 를 done 으로 override 하기 때문에,unscoped로 조회한 뒤 state 를 정상화하면 default_scope 문제가 해소된다. - 근거: migration operation 은 "새로 방금 만든 row" 를 다루므로 default_scope 를 신뢰하지 않고 명시적으로 조회하는 것이 안전하다.
- 주의:
unscoped를 세 지점(708, 755, 793) 모두 일관되게 적용해야 한다. 하나라도 놓치면 abandoned pano 가 있을 때 같은 실패가 재현된다.
두 옵션 중 A 를 먼저 적용하고 B 를 방어 코드로 함께 반영하는 것을 권장. B 만 적용하면 에러는 사라지지만 abandoned pano 의 새 row 가 계속 새 facility 에 남아 count/UI 부작용 가능성.
단기 개선 (1주 이내)#
migrate_panos실행 전에 pano_data 를 정규화하는 helper (예:filter_valid_panos_for_migration) 도입해state가abandoned인 항목과 tile 데이터가 없는 항목을 미리 걸러낸다. 이후migrate_tile_object,migrate_mask_object,set_pano_states_done모두 동일 새 id 배열을 사용하므로 정합성 유지.ImportWorker의rescue StandardError블록에서ActiveRecord::RecordNotFound를 별도로 감지해 "원본 데이터 이상 (abandoned/deleted row)" 으로 분류하고, retry 소진 전 fail-fast 하는 경로 추가. 현재는 결정론적 실패를 5회 반복해 로그와 지연이 불필요하게 늘어난다.- 이미 부분 커밋된 새 facility 하위 리소스(record, capture, pano row) 정합성 점검 및 정리 스크립트 준비 — migration 1843 관련 새 facility 아래에 orphan row 가 남아있을 가능성이 큼.
장기 개선 (재발 방지)#
- Migration 대상 데이터를 export 하는 시점(
ExportWorker쪽) 에서 이미Pano.where.not(state: :abandoned)(또는.activescope) 로 필터링해 abandoned row 가 pano.json 에 포함되지 않도록 한다. 근본 원인이 "abandoned 를 굳이 export" 하는 파이프라인 설계에 있으므로 소스 단계 차단이 가장 견고. - Migration operation 전반에서 default_scope 의존을 재검토. 방금 삽입한 row 를 곧바로
.find로 재조회하는 패턴은 default_scope 와 상성이 나쁨..unscoped또는insert_model!이 반환한 인스턴스를 재사용하는 리팩터링 검토. - Migration 실패 시 부분 커밋을 자동 정리하는 롤백 훅이 필요한지 검토. 현재는
IMPORT_PROCEED_CACHE_KEY로 이어서 실행하는 구조라 부분 실패 시 새 facility 아래 orphan data 가 남는다.
Monitoring#
- Migration import failure rate (Sidekiq job error count):
sum:trace.sidekiq.job.errors{service:cupixworks-migration-worker,resource_name:importworker}.as_count()
- Retries-exhausted 발생 추이 (ImportWorker):
service:cupixworks-migration-worker status:error "Database import retries exhausted"
- RecordNotFound (Pano) 발생 추이:
service:cupixworks-migration-worker status:error "ActiveRecord::RecordNotFound" "Couldn't find Pano"
알림 추가 권장: 24시간 창에서 위 세 쿼리 중 하나라도 count ≥ 1 이면 migration 담당 팀에 알림. Migration 은 low-volume 이라 임계치 1 로도 노이즈 없이 초기 감지 가능.
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard
- 근거: 논리는 단순 (state 필터 추가 또는
unscoped적용) 이지만 migration 파이프라인은 export/import/reprocess 3단계 워크플로우이며 부분 커밋된 row 정리 문제가 함께 있어 회귀 위험 존재. 특히 이미 실패한 migration 1843 의 새 facility 아래에 남은 부분 데이터는 별도 정리 작업이 필요. 코드 변경은 소규모이나 데이터 클린업이 리스크 요인.