ES /docs

Migration - rerun pano postprocessor: migration id(1772) / error(undefined method `size' for nil)

RCA: Migration rerun pano postprocessor — undefined method 'size' for nil

Overview#

What Happened#

2026-07-07 KST에 cupixworks-migration-workerMigrationReprocessWorker 가 migration id 1772 에 대해 pano 후처리 재실행 단계를 수행하다가 MigrationReprocessOperation#rerun_pano_postprocessor 에서 NoMethodError: undefined method 'size' for nil 이 발생했습니다. 이 migration 은 record/capture 없이 level·floorplan·workarea 만 이전한 케이스로, changed_key[:capture_id] 가 nil 인 상태에서 .size 를 호출했기 때문에 발생했습니다. 예외는 operation 내부에서 rescue 되어 worker 는 result: success 로 마감되었지만, pano 후처리 단계가 사실상 no-op 이라는 사실이 로그에서 은폐되었습니다.

Quick Facts#

Field Value
exception.class NoMethodError
exception.message undefined method `size' for nil
top_frame app/operations/migration_reprocess_operation.rb:13
env production, us-west-2
tenant cupix

Affected Teams#

Team / Domain Error Count Impact
cupixworks-migration-worker (Migration) 1 migration 1772 (facility 21385) 재처리 배치 진입 실패. capture 가 없어 실제 영향은 no-op 이지만 에러 로그로 남고, worker 는 success 로 오귀결됨

Timeline#

  1. 2026-07-07 16:58 KSTImportWorker 가 facility 21385 에 대해 migration 1772 의 import 단계를 마침. record ids([]), capture ids([]) — 실제 이전된 record/capture 없음.
  2. 2026-07-07 16:58 KSTImportWorkerMigrationReprocessWorker.perform_in(10.minute, data.to_json) 로 pano 후처리 재실행을 예약 (import_worker.rb:225).
  3. 2026-07-07 20:41:04 KSTMigrationReprocessWorker#perform 시작.
  4. 2026-07-07 20:41:04 KSTMigrationReprocessOperation#rerun_pano_postprocessor 에서 @changed_key[:capture_id].size 평가 중 NoMethodError 발생, operation 의 rescue StandardError 가 삼킴.
  5. 2026-07-07 20:41:04 KST — worker 는 예외를 인지하지 못하고 Migration reprocess success 로그를 남기며 완료.

Error Log#

Datadog Logs

text
Migration - rerun pano postprocessor: migration id(1772) / error(undefined method `size' for nil)

Impact#

  • Service: cupixworks-migration-worker
  • 발생 횟수: 1
  • 최초 발생: 2026-07-07 20:41 KST
  • 최근 발생: 2026-07-07 20:41 KST

실제 사용자 영향은 낮음. 문제의 migration 은 record/capture 없이 level·floorplan·workarea 만 이전한 케이스라 rerun 대상 capture 자체가 없었습니다. 다만 (1) 에러 로그가 오탐처럼 남고 (2) worker 는 success 로 종료해 상위 상태가 잘못 갱신되며 (3) 만약 실제 pano 재처리가 필요한 migration 에서 다른 이유로 changed_key[:capture_id] 가 nil 이 된다면 조용히 skip 될 위험이 있음.

Root Cause Summary#

MigrationReprocessOperation#rerun_pano_postprocessor@changed_key[:capture_id] 가 존재한다고 가정하고 .size.each_slice 를 호출합니다. 하지만 @changed_keyMigrationImportOperation 에서 save_foreign_key_change! 가 호출될 때에만 특정 foreign key 를 채우고, capture 이전이 실제로 일어나지 않으면 :capture_id 는 nil 인 채로 남습니다. 같은 파일의 다른 참조 지점들은 모두 @changed_key[key] || [] 로 nil 을 방어하는 반면, migration_reprocess_operation.rb 만 이 방어를 누락하여 capture-less migration 에서 NoMethodError 가 발생합니다. 예외는 operation 내부 rescue StandardError 가 삼키므로 worker 는 성공으로 오처리됩니다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/migration_reprocess_worker.rb:17MigrationReprocessWorker#perform
  • Enqueue site: app/workers/import_worker.rb:225 — import 완료 후 10분 지연으로 예약
  • Failure point: app/operations/migration_reprocess_operation.rb:13@changed_key[:capture_id].size 호출
  • Swallow point: app/operations/migration_reprocess_operation.rb:41-43rescue StandardError => e 가 예외를 로그로만 남기고 반환
app/operations/migration_reprocess_operation.rb:11-16ruby
  def rerun_pano_postprocessor
    batch_size = 10
    batch_idx = (@changed_key[:capture_id].size.to_f / batch_size).ceil

    @changed_key[:capture_id].each_slice(batch_size).with_index(1) do |capture_batch, idx|
      Cupix::Logger.info("Migration - rerun pano postprocessor: migration id(#{@migration_id}) / batch(#{idx}/#{batch_idx}) / capture ids(#{capture_batch.inspect})", class: self.class.name, method: __method__)

같은 파일에서 예외가 swallow 되는 지점:

app/operations/migration_reprocess_operation.rb:41-43ruby
  rescue StandardError => e
    Cupix::Logger.error("Migration - rerun pano postprocessor: migration id(#{@migration_id}) / error(#{e.message})", class: self.class.name, method: __method__)
  end

Import 쪽에서 동일한 필드에 접근할 때는 nil 방어를 하고 있음:

app/operations/migration_import_operation.rb:429ruby
    (@changed_key[:capture_id] || []).each do |changed|
app/operations/migration_import_operation.rb:456-460ruby
    if @changed_key[foreign_id].nil?
      @changed_key[foreign_id] = [{ from: from, to: to }]
    else
      @changed_key[foreign_id] << { from: from, to: to }
    end

@changed_key 는 hash 의 특정 키가 있을 때에만 채워지고, capture 이전이 발생하지 않으면 :capture_id 는 초기 상태 nil 로 남습니다. Reprocess operation 만 이 사실을 반영하지 않고 있습니다.

Worker 는 operation 이 예외를 삼킨 뒤 정상 반환하므로 그대로 성공 경로를 탑니다:

app/workers/migration_reprocess_worker.rb:44-47ruby
    reprocess_operation.rerun_pano_postprocessor
    result = 'success'
    Cupix::Logger.info("Migration reprocess success: migration id(#{migration_id}) / result(#{result})", class: self.class.name, method: __method__)
    MigrationOperation.check_import(migration_id: migration_id, result: 'success')

기대 동작: capture 이전 결과가 없는 migration 에서는 후처리 대상이 없다는 것을 명시적으로 로그하고 조용히 skip. 실제 동작: NoMethodError 발생 → error 로그 남김 → 그럼에도 상위 worker 는 success 로 마감.

Log Evidence#

Datadog query (재현용):

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

Import 단계에서 record/capture 가 비어 있음을 보여주는 로그 (같은 migration, 3시간 앞선 시점):

text
2026-07-07 16:58:11  info   ImportWorker           Database import -  migration id(1772): migrate record(...) for facility ids(21385)
2026-07-07 16:58:11  warn   MigrationImportOperation  Migration - import_datas: migration id(1772) / model(record) / data is nil, skipping
2026-07-07 16:58:11  info   ImportWorker           Database import -  migration id(1772): migrate capture(...) for record ids([])
2026-07-07 16:58:11  info   ImportWorker           Database import -  migration id(1772): migrate pano(pano) for capture ids([])
2026-07-07 16:58:11  info   MigrationImportOperation  Database import -  migration id(1772): update capture for capture ids([]) using editing ids([])
2026-07-07 16:58:11  info   ImportWorker           Database import success: migration id(1772)

10분 뒤 예약된 reprocess worker 에서 발생한 실패와, 뒤이어 남은 success 로그:

text
2026-07-07 20:41:04  info   MigrationReprocessWorker         Migration reprocess started: migration id(1772) retry_limit(5)
2026-07-07 20:41:04  info   MigrationReprocessWorker         Migration reprocess: migration id(1772) - last 50's pano postprocessor rerun ids([])
2026-07-07 20:41:04  error  MigrationReprocessOperation      Migration - rerun pano postprocessor: migration id(1772) / error(undefined method `size' for nil)
2026-07-07 20:41:04  info   MigrationReprocessWorker         Migration reprocess success: migration id(1772) / result(success)

successerror 가 같은 초에 공존하는 것이 root cause 의 부수 증상 — operation 이 예외를 삼키고 worker 에게 전파하지 않음.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 @changed_key[:capture_id] 가 nil 인 채 .size 호출 (capture-less migration) migration_reprocess_operation.rb:13 코드가 nil 방어 없음; import 로그에 capture ids([]), record ids([]), data is nil, skipping — capture 자체가 이전되지 않음. save_foreign_key_change! 는 실제 이전이 있을 때만 키를 채움 (migration_import_operation.rb:456-460). Confirmed
H2 Cache (CHANGED_CACHE_KEY) 손상으로 payload 구조가 유실됨 error 발생 → retry 시 캐시 저장 로직이 있음 (import_worker.rb:235) 실제 로그는 첫 시도에서 발생 (retry_limit(5) 시작 직후); import 성공 후 정상 payload 로 예약됨 (import_worker.rb:217-225); import 로그가 정상 완료 표시 Rejected
H3 외부 의존성(캐시/DB) 장애로 인한 데이터 누락 다른 migration 들은 같은 시간대 정상 처리; status board 에서 svc:cupixworks-migration-worker::unknown scope active null Rejected
H4 MigrationReprocessWorker 에서 JSON deserialize 시 symbol key 손상 (deep_symbolize_keys 미수행) migration_reprocess_worker.rb:18 에서 deep_symbolize_keys 호출 확인됨; 만일 손상이면 changed_key[:capture_id] 뿐 아니라 [:migration_id] 도 nil 이어야 하지만 로그에는 migration id(1772) 정상 노출 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/operations/migration_reprocess_operation.rb:13,15 에서 @changed_key[:capture_id] 접근에 nil 방어를 추가하고, capture 리스트가 비어 있으면 명시적으로 skip 로그를 남긴 뒤 조기 반환합니다. 같은 파일 다른 곳(migration_import_operation.rb:412, 429, 439, 1282, 1297)의 || [] 패턴과 일치시키는 것이 근거입니다.
  • 파일: app/operations/migration_reprocess_operation.rb (line 11-16 부근). 방향: capture_ids = @changed_key[:capture_id] || [] 로 로컬 바인딩 후 capture_ids.empty? 이면 info 로그와 함께 return.

단기 개선 (1주 이내)#

  • MigrationReprocessOperation#rerun_pano_postprocessorrescue StandardError => e (line 41-43) 를 재검토합니다. 현재 예외를 삼키고 worker 를 성공으로 만드는 구조는 pano 후처리 실패를 은폐합니다. MigrationReprocessWorker#perform 이 이미 상위에서 rescue StandardError 로 retry/캐시 처리를 수행하므로 (migration_reprocess_worker.rb:49-63), operation 레이어의 광범위한 rescue 는 제거하거나 raise 로 재전파하는 것이 안전합니다. 그래야 retry (MAX_RETRY_COUNT=5) 와 check_import(result: 'retrying'/'error') 상태 갱신이 실제로 동작합니다.
  • MigrationOperation.check_import(migration_id:, result: 'success') 호출 전에 pano 후처리가 실제로 스킵되었는지 여부를 명시적으로 구분해서 상위 상태에 반영합니다.

장기 개선 (재발 방지)#

  • @changed_key 를 raw hash 대신 Hash.new { |h, k| h[k] = [] } 같은 기본값 hash 로 표준화하거나 값 객체(ChangedKey) 로 감싸 nil 접근을 원천적으로 봉쇄합니다. 현재 코드베이스가 || [] 를 매 접근마다 반복하고 있어 이번처럼 한 곳만 누락되면 nil 예외가 재발할 수 있습니다.
  • Migration operation 전반의 예외 처리 컨벤션(“operation 은 raise, worker 는 rescue+retry”) 을 문서화합니다.

Monitoring#

Reprocess operation 의 무증상 실패를 조기 탐지하기 위한 쿼리:

text
service:cupixworks-migration-worker status:error "rerun pano postprocessor"

Worker 성공/실패 분리 추적:

text
service:cupixworks-migration-worker "Migration reprocess" (status:error OR status:success)

동일 migration id 에서 성공과 에러가 공존하는 이상 패턴 탐지를 위해 @migration_id 를 grouping 태그로 노출하는 것을 검토합니다. (현재 로그에는 class/function 태그만 있고 migration_id 는 메시지 안에 embedded 되어 있어 dashboard grouping 이 어렵습니다.)

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial

이번 인시던트 자체는 무해한 no-op migration 이었지만, 동일한 nil 미방어가 실제 pano 재처리가 필요한 migration 에서 다른 상류 결함과 결합되면 “조용한 후처리 누락” 으로 이어질 수 있어 우선 순위는 즉시 조치.