ES /docs

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-workerImportWorker (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_importresult: '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#

  1. 2026-07-09 07:06 KSTImportWorker가 migration 1843의 migrate_pano 단계에서 첫 실패. Pano#find(92228359) → RecordNotFound
  2. 2026-07-09 07:07 KST — Sidekiq 재시도 실패 (07:07:13, 07:07:45 등 5회 재시도)
  3. 2026-07-09 07:07:45 KSTsidekiq_retries_exhausted 훅 발동, Database import retries exhausted: migration id(1843) 에러 로그, MigrationOperation.check_import(result: 'error') 호출. SidekiqDeathHandler가 최종 3건의 death 로그 기록

Error Log#

Datadog Logs

text
Sidekiq job died after all retries

관련 실제 에러 (Datadog):

json
{
  "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_panosmigrate_model('pano', ...)으로 pano 행을 INSERT할 때, 원본(source)의 state 컬럼 값을 그대로 복사한다. 원본 pano들 중 일부는 이미 state = 'abandoned' 상태였고, 이 값이 그대로 신규 pano 행에 저장되었다. Pano 모델은 Statable::Panodefault_scope { where.not(state: :abandoned) } (app/models/concerns/statable/pano.rb:8) 이 걸려 있어 abandoned pano는 기본 쿼리에서 완전히 숨겨진다. 이어지는 migrate_tile_objectmodel_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:174ImportWorker#performmigrate_pano 스텝에서 각 capture별로 migrate_panos 호출
  • Pano insert: app/operations/migration_import_operation.rb:315migrate_model('pano', ...)insert_model!로 원본 데이터를 그대로 저장 (state 포함)
  • Default scope 적용: app/models/concerns/statable/pano.rb:8default_scope { where.not(state: :abandoned) }
  • Failure point: app/operations/migration_import_operation.rb:708Pano.find(new_id)가 abandoned 상태 pano를 조회 실패

Sidekiq worker 옵션 (재시도 5회):

app/workers/import_worker.rb:1-16ruby
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를 그대로 복제:

app/operations/migration_import_operation.rb:310-347ruby
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 pointfind가 default_scope를 그대로 사용:

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)   # ← 여기서 RecordNotFound
    storage_option = model_with_tile.storage_option

Default scope로 abandoned pano가 조회에서 배제됨:

app/models/concerns/statable/pano.rb:5-9ruby
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 검색 쿼리:

text
service:cupixworks-migration-worker status:error "Sidekiq job died after all retries"

핵심 에러 로그 (전체 스택트레이스는 위 Error Log 섹션 참고):

json
{
  "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"
}
json
{
  "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_exhaustedcheck_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:8default_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 발생 카운트를 대시보드에 노출.
text
service:cupixworks-migration-worker status:error @class:SidekiqDeathHandler "Sidekiq job died after all retries"
text
service:cupixworks-migration-worker status:error "ActiveRecord::RecordNotFound" "migration_import_operation.rb"
text
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 — findunscoped.find로 바꾸는 것은 몇 줄 수준이지만, mask/resource/tile 세 계열에 걸쳐 있고 회귀 테스트(원본 abandoned pano가 새 tenant에서 어떤 state로 저장되어야 하는지)에 대한 제품 결정이 필요.