ES /docs

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-workerImportWorker 가 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::Panodefault_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#

  1. 2026-07-09 07:06:05 KST — 첫 시도: migrate_capture 까지 완료하고 migrate_pano 단계에서 Pano 92228359 조회 실패
  2. 2026-07-09 07:06:39 KST — Sidekiq retry: Migration import - migration id(1843): sidekiq is retried
  3. 2026-07-09 07:06:53 KSTmigrate_pano 재실행, 동일 Pano id 로 재실패
  4. 2026-07-09 07:07:13 KST — 재시도 재실패
  5. 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#

Datadog Logs

text
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::Panodefault_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_pano step 진입)
  • 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 스택 트레이스에서 확인된 실제 실패 프레임:

Datadog stack tracetext
/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 의 흐름:

app/operations/migration_import_operation.rb:310-338ruby
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):

app/operations/migration_import_operation.rb:696-720ruby
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 포함)을 그대로 옮겨 담아 저장한다:

app/operations/migration_import_operation.rb:1032-1055ruby
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:

app/models/concerns/statable/pano.rb:5-19ruby
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:

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

Execution timeline:

text
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 핵심 부분:

text
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_capturemigrate_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) 도입해 stateabandoned 인 항목과 tile 데이터가 없는 항목을 미리 걸러낸다. 이후 migrate_tile_object, migrate_mask_object, set_pano_states_done 모두 동일 새 id 배열을 사용하므로 정합성 유지.
  • ImportWorkerrescue 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) (또는 .active scope) 로 필터링해 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):
text
sum:trace.sidekiq.job.errors{service:cupixworks-migration-worker,resource_name:importworker}.as_count()
  • Retries-exhausted 발생 추이 (ImportWorker):
text
service:cupixworks-migration-worker status:error "Database import retries exhausted"
  • RecordNotFound (Pano) 발생 추이:
text
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 아래에 남은 부분 데이터는 별도 정리 작업이 필요. 코드 변경은 소규모이나 데이터 클린업이 리스크 요인.