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#
- 2026-07-08 20:50 KST — Migration 1843 최초 시작 (
Database import(copy) begin, retry_limit 5) - 2026-07-08 20:50 KST — Sidekiq 이 이전 attempt 실패로 인해 retry 를 시작 (
Migration import ... sidekiq is retried) - 2026-07-08 21:14:22 KST —
migrate_panocurrent step 진입, 이미 capture(663015) 는 migrated 로 skip - 2026-07-08 21:14:28 KST — Pano bulk insert 완료 (
index - id: 92228359 - 92313891) - 2026-07-08 21:14:52 KST — Pano 원본 resource 복사 (
org_id(77468817)->new_id(92228359)) - 2026-07-08 21:15:07 KST —
migrate_tile_object에서Pano.find(92228359)실행 →RecordNotFound발생, job 실패 - 2026-07-08 21:15:07 KST —
delete_data_file로 export 디렉터리만 정리, import 디렉터리는 없음 (warn)
Error Log#
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:161perform→each_slice로 capture batch 순회 - 단계별 dispatch:
app/workers/import_worker.rb:174import_operation.migrate_panos(capture_id, pano_data)호출 - Pano insert:
app/operations/migration_import_operation.rb:315migrate_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:708—Pano.find(new_id)가 default_scope 로 필터링되어 raise
migrate_model 은 원본 데이터를 상태 필터 없이 그대로 insert 한다:
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 는 무시한다:
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 호출이 실패한다:
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 를 제외한다:
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일 범위):
service:cupixworks-migration-worker "migration id(1843)"
service:cupixworks-migration-worker "92228359"
핵심 로그 시퀀스:
{
"timestamp": "2026-07-08 20:50:38",
"status": "info",
"message": "Database import(copy) begin - migration id(1843) retry_limit(5)",
"class": "ImportWorker"
}
{
"timestamp": "2026-07-08 20:50:48",
"status": "warn",
"message": "Migration import - migration id(1843): sidekiq is retried",
"class": "Class",
"function": "migrationable?"
}
{
"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"
}
{
"timestamp": "2026-07-08 21:14:28",
"status": "info",
"message": "index - id: 92228359 - 92313891",
"class": "Pano",
"function": "bulk_operation!"
}
{
"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"
}
{
"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::Panodefault_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_ids 와 pano_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:708migrate_tile_object의Pano.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:
service:cupixworks-migration-worker status:error "Import model failed"
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.find→Pano.unscoped.find교체는 trivial 이지만 abandoned pano 의 tile/mask 를 실제로 복사할 것인지 정책 결정 및 부분 insert 정리는 담당팀 협의 필요.