Sidekiq job died after all retries
RCA: Sidekiq job died after all retries — Pano not found in migrate_tile_object
Overview#
What Happened#
2026-07-09 07:06 KST에 cupixworks-migration-worker의 ImportWorker (Sidekiq, queue migration) 가 migration id 1843 처리 중 MigrationImportOperation#migrate_tile_object에서 ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE panos.state != ?] 예외를 반복적으로 발생시켰고, MAX_RETRY_COUNT 5회 재시도가 모두 실패하여 SidekiqDeathHandler가 3건의 Sidekiq job died after all retries 에러를 기록했다. Migration 1843의 데이터 임포트가 중단되어 MigrationOperation.check_import가 result: 'error'로 마감되었다.
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 |
| env | production, us-west-2 |
| tenant | cupix |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| migration (ImportWorker) | 3 | Migration id 1843 임포트 실패 (check_import result: error). 해당 마이그레이션 요청자의 데이터 복제 실패 |
Timeline#
- 2026-07-09 07:06 KST —
ImportWorker가 migration 1843의migrate_pano단계에서 첫 실패.Pano#find(92228359)→ RecordNotFound - 2026-07-09 07:07 KST — Sidekiq 재시도 실패 (07:07:13, 07:07:45 등 5회 재시도)
- 2026-07-09 07:07:45 KST —
sidekiq_retries_exhausted훅 발동,Database import retries exhausted: migration id(1843)에러 로그,MigrationOperation.check_import(result: 'error')호출.SidekiqDeathHandler가 최종 3건의 death 로그 기록
Error Log#
Sidekiq job died after all retries
관련 실제 에러 (Datadog):
{
"timestamp": "2026-07-09 07:07:45",
"status": "error",
"message": "Sidekiq job died after all retries",
"class": "SidekiqDeathHandler",
"function": "death_handler",
"error": {
"msg": "Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]",
"stack": [
"activerecord-7.2.2/lib/active_record/relation/finder_methods.rb:428:in `raise_record_not_found_exception!'",
"app/operations/migration_import_operation.rb:708:in `block in migrate_tile_object'",
"app/operations/migration_import_operation.rb:701:in `each_with_index'",
"app/operations/migration_import_operation.rb:701:in `migrate_tile_object'",
"app/operations/migration_import_operation.rb:336:in `migrate_panos'",
"app/workers/import_worker.rb:174:in `block (2 levels) in perform'"
]
}
}
Impact#
- Service:
cupixworks-migration-worker - 발생 횟수: 3
- 최초 발생: 2026-07-09 07:06 KST
- 최근 발생: 2026-07-09 07:07 KST
- Blast radius: 단일 migration id (1843) 한 건. 다른 migration 작업에는 영향 없음. 다만 마이그레이션 처리가 중단된 이후 대상 tenant의 사본 데이터가 불완전한 상태로 남는다.
Root Cause Summary#
MigrationImportOperation#migrate_panos가 migrate_model('pano', ...)으로 pano 행을 INSERT할 때, 원본(source)의 state 컬럼 값을 그대로 복사한다. 원본 pano들 중 일부는 이미 state = 'abandoned' 상태였고, 이 값이 그대로 신규 pano 행에 저장되었다. Pano 모델은 Statable::Pano에 default_scope { where.not(state: :abandoned) } (app/models/concerns/statable/pano.rb:8) 이 걸려 있어 abandoned pano는 기본 쿼리에서 완전히 숨겨진다. 이어지는 migrate_tile_object가 model_name.camelize.constantize.find(new_id) (migration_import_operation.rb:708) 로 방금 삽입한 pano를 조회하는 순간, default_scope가 WHERE panos.state != 'abandoned' 필터를 부여하여 ActiveRecord::RecordNotFound가 발생한다. migrate_tile_object 앞단의 skip 조건 (from_tile.blank? / s3_object_keys / tile_size 확인, line 705-706) 은 tile 데이터 유무만 검사할 뿐 pano state를 확인하지 않으므로, tile 정보를 가진 abandoned 원본 pano가 이 오류 경로를 그대로 탄다. Sidekiq 재시도는 데이터베이스 상태가 이미 확정된 후이므로 5회 모두 동일 에러로 실패했다.
Technical Analysis#
Code Path#
- Entry point:
app/workers/import_worker.rb:174—ImportWorker#perform이migrate_pano스텝에서 각 capture별로migrate_panos호출 - Pano insert:
app/operations/migration_import_operation.rb:315—migrate_model('pano', ...)이insert_model!로 원본 데이터를 그대로 저장 (state 포함) - Default scope 적용:
app/models/concerns/statable/pano.rb:8—default_scope { where.not(state: :abandoned) } - Failure point:
app/operations/migration_import_operation.rb:708—Pano.find(new_id)가 abandoned 상태 pano를 조회 실패
Sidekiq worker 옵션 (재시도 5회):
class ImportWorker
include Sidekiq::Worker
MAX_RETRY_COUNT = 5
sidekiq_options queue: :migration, retry: MAX_RETRY_COUNT
sidekiq_retries_exhausted do |job, e|
data = JSON.parse(job['args'].first).deep_symbolize_keys
migration_id = data[:migration_id]
Cupix::Logger.error("Database import retries exhausted: migration id(#{migration_id}) - #{e.class}: #{e.message}", class: name, method: 'sidekiq_retries_exhausted')
MigrationOperation.check_import(migration_id: migration_id, result: 'error')
end
Pano 삽입 흐름 — abandoned state를 그대로 복제:
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])
# ...
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)
# ...
end
end
Failure point — find가 default_scope를 그대로 사용:
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) # ← 여기서 RecordNotFound
storage_option = model_with_tile.storage_option
Default scope로 abandoned pano가 조회에서 배제됨:
included do
include ::Statable
default_scope { where.not(state: :abandoned) }
기대 동작 vs 실제 동작:
- 기대:
migrate_model이 방금 INSERT한 pano id는 뒤이은find에서 반드시 조회 가능해야 한다. - 실제: 원본에서
state='abandoned'였던 pano는 신규 행도 abandoned 상태로 저장되고,Pano.find가 default_scope에 걸려RecordNotFound를 던진다.migrate_tile_object는 원본 tile 페이로드 유무만 skip 조건으로 삼기 때문에 abandoned pano가 이 코드 경로로 유입된다.
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-migration-worker status:error "Sidekiq job died after all retries"
핵심 에러 로그 (전체 스택트레이스는 위 Error Log 섹션 참고):
{
"timestamp": "2026-07-09 07:07:45",
"status": "error",
"message": "Database import retries exhausted: migration id(1843) - ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]",
"class": "ImportWorker"
}
{
"timestamp": "2026-07-09 07:07:45",
"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"
}
이벤트 타임라인 (3건의 death 로그, 모두 동일 pano id 92228359):
| Timestamp (KST) | Event |
|---|---|
| 2026-07-09 07:06:05 | 1차 Sidekiq death 로그 |
| 2026-07-09 07:07:13 | 2차 Sidekiq death 로그 (retry) |
| 2026-07-09 07:07:45 | 3차 death 로그 + sidekiq_retries_exhausted → check_import(result: 'error') |
동일 시간대 다른 migration id 관련 에러는 관측되지 않음 (검색 쿼리 service:cupixworks-migration-worker "migration id(1843)" 30건 확인). 즉 단일 migration에 국한된 문제.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 원본 pano의 state='abandoned'가 INSERT 시 그대로 복사되어 default_scope에 걸림 |
pano.rb include Statable::Pano, statable/pano.rb:8에 default_scope { where.not(state: :abandoned) }. insert_model! (line 1032-1060)이 원본 데이터의 모든 컬럼(state 포함)을 그대로 저장. migrate_tile_object의 skip 조건(705-706)은 tile 페이로드만 검사, state 미확인. 에러 메시지 [WHERE panos.state != ?]가 정확히 default_scope 필터와 일치. |
— | Confirmed |
| H2 | Pano가 DB에서 물리적으로 삭제(DELETE)되어 사라졌다 | 없음 | 에러 SQL에 WHERE panos.state != ? 조건이 명시적으로 붙는다는 것은 행이 존재하되 default_scope 필터에 걸린다는 신호. 물리 삭제라면 해당 조건 없이도 RecordNotFound가 발생. |
Rejected |
| H3 | 동시성/race — 다른 Sidekiq job이 pano state를 abandoned로 바꾼 것 | not_processible 스코프에 걸린 pano를 abandoned로 전이시키는 코드가 존재 (capture_invoker.rb:29-30, processible_capture/resumable.rb:76) |
로그상 migration 1843 진행 중 다른 워커의 pano state 변경 흔적 없음. 세 번의 retry 모두 동일 pano id/동일 예외 반복 — race라면 재시도로 통과할 확률이 있어야 하지만 그렇지 않음. 즉 abandoned 상태가 지속적이며 원본에서 유래한 가능성이 높음. | Rejected |
| H4 | Sidekiq::Shutdown 재시도 시나리오에서 inserted? 캐시 미스 → new_ids 배열 정렬 문제 |
코드에 inserted? 재시도 처리 로직 존재 (line 1132-1140) |
이 경로는 existing_mapping[:to]를 기록하며, 실패 시 nil이 배열에 들어가 다른 예외(nil find)를 유발. 현재 에러는 실제 pano id 92228359를 조회하며 발생 — new_id는 정상적으로 계산됨. |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
app/operations/migration_import_operation.rb:708(그리고 유사 패턴의migrate_resource_object,migrate_mask_object,migrate_pointcloud_object) 에서 방금 삽입된 pano를 조회할 때 default_scope를 우회하도록Pano.unscoped.find(new_id)(또는Pano.unscope(:where).find(new_id)) 로 변경. 근거: 마이그레이션 컨텍스트에서는 원본 데이터를 그대로 복제하는 것이 목적이며, 원본이 abandoned인지 여부는 tile/mask/resource 페이로드 존재 여부와 독립적으로 결정되어야 한다.- 대안적 접근:
migrate_tile_object(그리고 mask/resource 계열) 진입 시 abandoned/error 상태의 원본 pano를 skip하도록 명시적 필터 추가 (예: 원본 pano_data의state를 확인). 근거: abandoned pano의 tile 데이터는 이후 사용되지 않을 가능성이 높으므로 처리 자체를 건너뛰는 것이 논리적으로도 정합적.
단기 개선 (1주 이내)#
ImportWorker에서ActiveRecord::RecordNotFound를 잡아 어떤 pano/resource가 누락됐는지 구조화된 로그(migration_id,capture_id,pano_id,state)로 남기고, retry 전에 즉시 abort하도록 처리. 근거: 현재는 동일 예외를 5회 재시도해도 자연 회복 여지가 없으므로 재시도 비용이 무의미하다.MigrationImportOperation에서 pano/mask/resource 대상 조회를 반복적으로 수행하는 부분을 헬퍼(예:find_migrated_pano(new_id))로 통일하여unscoped정책을 한 곳에서 관리.
장기 개선 (재발 방지)#
- 마이그레이션 대상 데이터에 대해 default_scope를 신뢰하지 않는다는 컨벤션을 도큐먼트화하고, 다른 모델(
Capture,Video,Pointcloud,Floorplan등)에서도 default_scope와 마이그레이션 조회가 충돌하는지 감사 필요. 특히state != :abandoned계열 default_scope가 있는 모델을 대상으로 점검. - 원본 export 단계에서 abandoned state pano/resource를 사전에 필터링하거나, import 단계에서 abandoned 원본을 명시적으로 skip하는 정책 결정 필요 (제품/데이터 스펙 결정 사항).
Monitoring#
ImportWorker가 exhaust되는 사건과MigrationImportOperation관련RecordNotFound발생 카운트를 대시보드에 노출.
service:cupixworks-migration-worker status:error @class:SidekiqDeathHandler "Sidekiq job died after all retries"
service:cupixworks-migration-worker status:error "ActiveRecord::RecordNotFound" "migration_import_operation.rb"
service:cupixworks-migration-worker status:error "Database import retries exhausted"
- Datadog Monitor: 위 첫 번째 쿼리에 대해 5분 창에서 1건 이상 발생 시 알림 (release dashboard timeseries widget에 그대로 embed 가능한 log-search 문법 유지,
| stats/count by(...)미사용).
Risk Assessment#
- Risk level: medium — 단일 migration 실패로 blast radius는 좁지만, 원본 데이터에 abandoned pano가 포함된 tenant마다 재발 가능. 마이그레이션 트래픽이 늘거나 abandoned pano 비중이 높은 tenant에서 반복 발생 위험.
- 예상 복잡도: standard —
find를unscoped.find로 바꾸는 것은 몇 줄 수준이지만, mask/resource/tile 세 계열에 걸쳐 있고 회귀 테스트(원본 abandoned pano가 새 tenant에서 어떤 state로 저장되어야 하는지)에 대한 제품 결정이 필요.