ES /docs

Import model failed: migration id(1843) last step(migrate_capture) - ActiveRecord::RecordNotFound: C

RCA: Import model failed: migration id(1843) last step(migrate_capture) - ActiveRecord::RecordNotFound Pano id=92228359

Overview#

What Happened#

2026-07-08 21:15 KST, cupixworks-migration-worker 서비스에서 facility 18180 (team walmart-tst, team_id 1257) 대상 migration id 1843 의 ImportWorker Sidekiq job이 migrate_pano 단계에서 실패했다. MigrationImportOperation#migrate_tile_object 내부 Pano.find(new_id) 호출이 새로 insert 된 pano id 92228359 을 찾지 못해 ActiveRecord::RecordNotFound 를 발생시켰으며, 이는 원본 pano(id 77468817) 가 state=abandoned 였고 그 값이 그대로 복제되어 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 / Rails 7.2.2 / Sidekiq 7.3.9
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
walmart-tst (team_id 1257) 1 Facility 18180 migration 1843 중단 — retry 실패 후 dead queue 로 이동 예상

Timeline#

  1. 2026-07-08 20:50 KST — Migration 1843 최초 시작 (Database import(copy) begin, retry_limit 5)
  2. 2026-07-08 20:50 KST — Sidekiq 이 이전 attempt 실패로 인해 retry 를 시작 (Migration import ... sidekiq is retried)
  3. 2026-07-08 21:14:22 KSTmigrate_pano current step 진입, 이미 capture(663015) 는 migrated 로 skip
  4. 2026-07-08 21:14:28 KST — Pano bulk insert 완료 (index - id: 92228359 - 92313891)
  5. 2026-07-08 21:14:52 KST — Pano 원본 resource 복사 (org_id(77468817)->new_id(92228359))
  6. 2026-07-08 21:15:07 KSTmigrate_tile_object 에서 Pano.find(92228359) 실행 → RecordNotFound 발생, job 실패
  7. 2026-07-08 21:15:07 KSTdelete_data_file 로 export 디렉터리만 정리, import 디렉터리는 없음 (warn)

Error Log#

Datadog Logs

text
Import model failed: migration id(1843) last step(migrate_capture) - 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/app/operations/migration_import_operation.rb:708:in `block in migrate_tile_object'
/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'

Impact#

  • Service: cupixworks-migration-worker
  • 발생 횟수: 1
  • 최초 발생: 2026-07-08 21:15 KST
  • 최근 발생: 2026-07-08 21:15 KST
  • 범위: walmart-tst 테넌트의 facility 18180 migration 1개. 다른 facility/tenant 로의 확산은 관측되지 않음 (Datadog 14일 조회 결과 pano 92228359 관련 로그 3건뿐).

Root Cause Summary#

원본 pano 77468817 은 state=abandoned 상태로 export 되었고, MigrationImportOperation#import_datas 는 pano state 를 필터하지 않고 그대로 insert 하기 때문에 새로 생성된 pano 92228359 도 state=abandoned 로 삽입된다. 이어지는 migrate_tile_object (line 708) 는 model_name.camelize.constantize.find(new_id)Pano.find 를 호출하는데, Statable::Pano concern 이 default_scope { where.not(state: :abandoned) } 를 강제하므로 방금 만든 record 임에도 ActiveRecord::RecordNotFound 가 발생한다. 즉, 데이터 유효성 문제(원본 abandoned)와 조회 API 선택 오류(Pano.find — default_scope 적용) 의 조합이 root cause이다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/import_worker.rb:161 performeach_slice 로 capture batch 순회
  • 단계별 dispatch: app/workers/import_worker.rb:174 import_operation.migrate_panos(capture_id, pano_data) 호출
  • Pano insert: app/operations/migration_import_operation.rb:315 migrate_model('pano', ...) — export JSON의 pano 데이터를 그대로 INSERT
  • Tile 조건 분기: app/operations/migration_import_operation.rb:329-334 — 모든 pano 의 tile_size == 3 일 때 migrate_tile_object 호출
  • Failure point: app/operations/migration_import_operation.rb:708Pano.find(new_id) 가 default_scope 로 필터링되어 raise

migrate_model 은 원본 데이터를 상태 필터 없이 그대로 insert 한다:

app/operations/migration_import_operation.rb:1124-1143ruby
datas.each do |data|
  _data = data[:data]
  from_id = _data['id']
  original_ids << from_id

  change_copy_foreign_key!(model_name, _data)
  set_value_for_not_null(model_name, _data)

  if inserted?(foreign_id: model_id, from: from_id)
    existing_mapping = @changed_key[model_id]&.find { |c| c[:from] == from_id }
    inserted_ids << existing_mapping[:to] if existing_mapping
    next
  end

  inserted = insert_model!(model, _data)
  inserted_ids << inserted.id

import_datas 는 parent 관계로만 필터하고 pano state 는 무시한다:

app/operations/migration_import_operation.rb:583-599ruby
else
  filtered = model_data.filter do |key, value|
    next true if parent.blank?

    data = value['data']
    parent_foreign_key = "#{parent_name}_id"
    polymorphic_key = "#{model_name}able_id"

    data[parent_foreign_key] == original_parent_id ||
      (data.key?(polymorphic_key) && data[polymorphic_key] == original_parent_id)
  end

이후 tile 복사 단계에서 default_scope 를 우회하지 않는 find 호출이 실패한다:

app/operations/migration_import_operation.rb:696-708ruby
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)  # ← Pano.find → default_scope 적용

Statable::Pano concern 이 default_scope 로 abandoned pano 를 제외한다:

app/models/concerns/statable/pano.rb:5-9ruby
included do
  include ::Statable

  default_scope { where.not(state: :abandoned) }

기대 동작 vs 실제 동작:

  • 기대: 방금 insert 한 pano id 를 tile 복사 대상으로 조회 → 발견
  • 실제: insert 된 pano 가 state=abandoned 라서 default_scope 필터에 걸림 → RecordNotFound → 전체 migration 실패

Log Evidence#

Datadog 쿼리 (14일 범위):

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

핵심 로그 시퀀스:

json
{
  "timestamp": "2026-07-08 20:50:38",
  "status": "info",
  "message": "Database import(copy) begin -  migration id(1843) retry_limit(5)",
  "class": "ImportWorker"
}
json
{
  "timestamp": "2026-07-08 20:50:48",
  "status": "warn",
  "message": "Migration import -  migration id(1843): sidekiq is retried",
  "class": "Class",
  "function": "migrationable?"
}
json
{
  "timestamp": "2026-07-08 21:14:22",
  "status": "info",
  "message": "Database import -  migration id(1843): last step(migrate_capture) / current_step(migrate_pano)",
  "class": "ImportWorker"
}
json
{
  "timestamp": "2026-07-08 21:14:28",
  "status": "info",
  "message": "index - id: 92228359 - 92313891",
  "class": "Pano",
  "function": "bulk_operation!"
}
json
{
  "timestamp": "2026-07-08 21:14:52",
  "status": "info",
  "message": "migrate_resource_object - copying version 1: migration_id(1843), model(pano), org_id(77468817)->new_id(92228359), download_url(https://s3.us-west-1.amazonaws.com/cupixworks-source-888a512bf858-uswe1/resou...), upload_key(resources/6ugs8d/uswe1/v1)",
  "class": "MigrationImportOperation"
}
json
{
  "timestamp": "2026-07-08 21:15:07",
  "status": "error",
  "message": "Import model failed: migration id(1843) last step(migrate_capture) - ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]",
  "class": "ImportWorker"
}

증거 요약:

  • org_id(77468817)->new_id(92228359) 매핑 로그로 원본/신규 id 관계 확정
  • bulk_operation! 로그로 pano insert 자체는 성공했음이 확인됨 (id 92228359 은 실제로 DB 에 존재)
  • 에러 메시지 SQL 필터 [WHERE panos.state != ?]Statable::Pano default_scope 와 정확히 일치
  • retry 시나리오이지만 이번 실패는 retry 자체가 아니라 원본 abandoned pano 데이터가 원인

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 원본 pano가 state=abandoned 상태로 export 되어 새 pano 도 abandoned 로 insert 되고, Pano.find default_scope 가 이를 제외해 RecordNotFound 발생 에러 메시지의 [WHERE panos.state != ?]Statable::Pano default_scope와 일치. bulk_operation! 로그로 id 92228359 insert 성공 확인. migrate_model (line 1124-1143) 은 state 필터 없이 원본 data 를 그대로 insert. import_datas (line 583-599) 도 state 를 필터하지 않음 Confirmed
H2 Sidekiq retry 중 이전 attempt 에서 insert 된 pano 를 delete 하고 다시 insert 하다가 race condition 발생 20:50 에 retry 로그 존재 inserted? 체크(line 1132)로 이미 migrated 된 항목은 skip 하므로 중복 insert 없음. bulk_operation! 로그가 21:14:28 에 한 번만 나타나고 그 직후 Pano.find 실패. 시간 gap 없이 순차 실행 Rejected
H3 capture_org_idspano_new_ids 배열 정렬 불일치로 잘못된 new_id 를 조회 배열 정렬 관련 코드가 line 316-317 에 존재 로그의 org_id(77468817)->new_id(92228359) 매핑이 실제로 존재하며 pano 92228359 는 DB 에서 확인됨. 에러도 "잘못된 id" 가 아니라 "state 필터에 걸림" 임을 SQL 이 증명 Rejected
H4 Pano 92228359 가 다른 프로세스에 의해 abandoned 상태로 갱신됨 (외부 race) migration_import 는 격리된 새 record 를 만들며 외부에서 이 신규 id 에 접근할 경로가 없음. 21:14:28 insert 후 39초 만에 조회되므로 외부 mutation window 도 매우 작음. 원본 abandoned 복제 가설(H1)이 훨씬 단순함 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/operations/migration_import_operation.rb:708 migrate_tile_objectPano.find(new_id) 를 default_scope 를 우회하는 조회로 교체. 예: Pano.unscoped.find(new_id) 또는 명시적 Pano.unscope(:where).find(new_id). 이유: insert 는 성공했으므로 record 는 반드시 존재하며, tile 복사 로직은 pano state 와 무관하게 진행되어야 함.
  • 동일 패턴이 migrate_mask_object (line 755, Pano.find(new_id)) 및 다른 find 호출들에 존재하는지 확인 후 함께 조치. Grep 결과 migration_import_operation.rb 에는 constantize.find(new_id) 패턴이 tile 경로에만 있지만, Pano.find(new_id) 는 mask 경로에도 있으므로 대칭 수정 필요.
  • 실패한 migration 1843 은 abandoned pano 를 포함한 상태로 이미 부분 insert 됨. 수동 rerun 전에 이번 attempt 로 insert 된 pano/resource record 정리 여부를 담당팀이 판단해야 함 (사용자 영향은 walmart-tst 단일 facility).

단기 개선 (1주 이내)#

  • import_datas (line 549) 또는 export 측에서 abandoned pano 를 원천적으로 제외하도록 필터 추가. abandoned pano 는 사용자 노출되지 않는 tombstone 이므로 migration 시 그대로 옮길 이유가 낮다. Export 쪽 필터가 이상적이나 tesla repo 내에서 방어하려면 import_datas 에서 state != 'abandoned' 를 skip 하는 방향.
  • retry idempotency 강화: inserted? 체크는 있으나 migrate_tile_object 는 매 attempt 마다 재실행됨. tile/mask 복사 완료 여부를 checkpoint 로 저장해 retry 시 skip 하면 유사 실패 시 blast radius 축소.
  • Cupix::Logger.error 에 실패한 pano id (원본/신규 매핑) 를 별도 필드로 기록하도록 로그 개선 (현재는 exception message 안에만 있음).

장기 개선 (재발 방지)#

  • Migration import 파이프라인 전반에서 find 호출을 감사해 default_scope 가 예기치 않게 record 를 숨기는 지점 파악. 특히 Statable::* concern 을 포함한 모델 (Pano, 그리고 다른 statable model) 모두에서 동일 리스크.
  • Migration export/import 사양에 "state 필터 정책" 을 명문화 (어떤 state 를 옮기고 어떤 state 는 skip 할지) 및 CI 에서 export fixture 로 validation.
  • 실패한 partial migration 을 rollback 하거나 재시작 가능한 checkpoint 모델 (예: pano_migrated_capture_ids 처럼 tile 단계도 tracking) 도입.

Monitoring#

  • 추가할 Datadog timeseries widget:
text
service:cupixworks-migration-worker status:error "Import model failed"
text
service:cupixworks-migration-worker status:error "ActiveRecord::RecordNotFound" "Pano"
  • 알림: 위 첫 번째 쿼리가 15분 rolling window 에서 1회 이상 시 Slack 알림. Migration 실패는 사용자 데이터 이관 실패이므로 즉시 인지가 중요.
  • Sidekiq retry 로그 (Migration import ... sidekiq is retried) 를 별도 카운터로 추적해 특정 migration 이 반복 retry 되는지 감시.

Risk Assessment#

  • Risk level: medium — 단일 tenant/facility 에 국한되었으나 abandoned pano 를 포함한 다른 tenant migration 이 시도되면 동일하게 실패한다. Fix 는 국소적이나 부분 insert 로 인한 데이터 정합성 확인이 필요.
  • 예상 복잡도: standard — Pano.findPano.unscoped.find 교체는 trivial 이지만 abandoned pano 의 tile/mask 를 실제로 복사할 것인지 정책 결정 및 부분 insert 정리는 담당팀 협의 필요.