ES /docs

Capture move validation failed: migration id(1620) - Record 9015 does not belong to source facility

RCA: Capture move validation failed — Record does not belong to source facility

Overview#

What Happened#

2026-06-22 22:42:50 KST에 cupixvista-api-migration-worker에서 MoveWorker가 migration 1620을 처리하던 중 Capture move validation failed 에러를 발생시켰다. 동일한 record_id=9015에 대해 직전 migration 1619가 같은 시각(22:42:50 KST)에 facility 4232 → 7145 이동을 성공시킨 직후였으며, migration 1620은 또다시 source_facility_id=4232 기준으로 이동을 시도해 validate_and_prepare_move의 defensive check에 걸렸다. 14일 retention 내에서 production 발생은 본 1건이다.

Quick Facts#

Field Value
exception.class Cupix::Errors::Parameter (code ARG10000)
exception.message Record 9015 does not belong to source facility 4232 (current: 7145). It may have been moved by another operation.
top_frame app/workers/move_worker.rb:76 (rescue/log site), raised at app/workers/move_worker.rb:178-181
runtime Ruby on Rails — Sidekiq worker, queue :migration
env production, region us-west-2, tenant cupix

Affected Teams#

Team / Domain Error Count Impact
CupixVista Migration (cupixvista-api-migration-worker) 1 Migration 1620의 record 9015 이동 시도 실패. Defensive check가 작동하여 데이터 손상은 없고, MigrationOperation.check_move(... result: 'error')로 표기된 채 retry 없이 종료.

Timeline#

  1. 2026-06-22 22:42:14 KSTRecord move begin - migration id(1619) / source record id(9015) / target facility id(7145) (MoveWorker#perform, move_worker.rb:81)
  2. 2026-06-22 22:42:14 KST — Record, Spacetime, Video, Capture, Cluster, Pointcloud, Mesh, EditingEntity 단계 완료 로그 기록
  3. 2026-06-22 22:42:26 KST — Pano, Review 단계 완료 (MigrationMoveOperation#move_reviews!)
  4. 2026-06-22 22:42:50 KSTRecord move success: migration id(1619) / record(9015) / 4232 -> 7145 (move_worker.rb:115)
  5. 2026-06-22 22:42:50 KSTCapture move validation failed: migration id(1620) - Record 9015 does not belong to source facility 4232 (current: 7145). (move_worker.rb:76) — 본 인시던트

Error Log#

Datadog Logs

text
Capture move validation failed: migration id(1620) - Record 9015 does not belong to source facility 4232 (current: 7145). It may have been moved by another operation.

Impact#

  • Service: cupixvista-api-migration-worker
  • 발생 횟수: 1
  • 최초 발생: 2026-06-22 22:42:50 KST
  • 최근 발생: 2026-06-22 22:42:50 KST
  • 데이터 무결성: 영향 없음. Defensive check가 잘못된 두 번째 이동을 트랜잭션 진입 전에 차단했으므로 record 9015은 정상적으로 facility 7145에 위치한다.
  • 사용자 영향: Migration 1620 요청은 실패 상태(result: 'error')로 종료. 동일 record에 대한 후속 이동은 새 migration request로 재시도 가능.

Root Cause Summary#

동일한 source_record_id=9015에 대해 두 개의 migration request(1619, 1620)가 거의 동시에 생성되었고, 모두 source_facility_id=4232를 가진 채 MoveWorker.perform_async로 enqueue되었다. Migration 1619가 먼저 완료되어 record 9015의 facility_id를 7145로 갱신하자, 곧이어 실행된 migration 1620은 MoveWorker#validate_and_prepare_move의 방어 조건(record.facility_id != source_facility_id)에 걸려 Cupix::Errors::Parameter를 일으켰다. 즉 본 로그는 두 개의 모순된 이동 요청이 들어왔을 때 두 번째 요청이 데이터를 손상시키지 않도록 설계된 방어 코드가 정상 동작한 결과이며, 진짜 원인은 동일 record에 대한 중복/충돌 migration이 생성되는 상류 플로우에 있다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/migrations_controller.rb:47MigrationsController#move가 Evergreen에서 migration 상세를 가져와 MoveWorker.perform_async로 enqueue.
  • Worker entry: app/workers/move_worker.rb:63MoveWorker#perform이 JSON 인자를 파싱.
  • Validation: app/workers/move_worker.rb:73validate_and_prepare_move 호출.
  • Failure point: app/workers/move_worker.rb:177-181record.facility_id != source_facility_id이면 Cupix::Errors::Parameter raise.
  • Rescue + log: app/workers/move_worker.rb:74-79Cupix::Errors::Parameter만 잡아 error 로그를 남기고 MigrationOperation.check_move(... result: 'error') 호출 후 return (no retry).
app/workers/move_worker.rb:71-79ruby
# Validate and prepare move parameters
begin
  facility_org_id, source_level_id, target_level_id = validate_and_prepare_move(source_record_id, source_facility_id, target_facility_id)
rescue Cupix::Errors::Parameter => e
  # Handle validation errors without retry (e.g., missing record, capture, or target level)
  Cupix::Logger.error("Capture move validation failed: migration id(#{migration_id}) - #{e.message}", class: self.class.name, method: __method__)
  MigrationOperation.check_move(migration_id: migration_id, result: 'error')
  return
end
app/workers/move_worker.rb:171-195ruby
def validate_and_prepare_move(source_record_id, source_facility_id, target_facility_id)
  record = Record.find_by(id: source_record_id)
  raise Cupix::Errors::Parameter.new(code: 'ARG10000', reason: "Record #{source_record_id} not found") if record.nil?

  # Defensive check: Verify the record still belongs to the expected source facility
  # This prevents concurrent move operations on the same record to different facilities
  if record.facility_id != source_facility_id
    raise Cupix::Errors::Parameter.new(
      code: 'ARG10000',
      reason: "Record #{source_record_id} does not belong to source facility #{source_facility_id} (current: #{record.facility_id}). It may have been moved by another operation."
    )
  end
  ...
end

기대 동작: 한 record는 한 시점에 하나의 in-flight migration만 가져야 하며, defensive check는 본래 race condition을 catch하기 위한 안전망이다. 실제 동작: 두 개의 migration(1619, 1620)이 동일한 (record_id=9015, source_facility=4232) 조합으로 enqueue되었고, 두 번째가 첫 번째 완료 직후 실행되어 안전망이 발동했다.

Log Evidence#

Datadog 쿼리:

text
service:cupixvista-api-migration-worker "record(9015)"

핵심 로그(시간 순):

text
2026-06-22 22:42:14 KST  info   Record move begin - migration id(1619) / source record id(9015) / target facility id(7145) / retry_limit(5)
2026-06-22 22:42:14 KST  info   Record move - migration id(1619): completed Record
2026-06-22 22:42:26 KST  info   Record move - migration id(1619): completed Pano
2026-06-22 22:42:26 KST  info   Record move - migration id(1619): completed Review
2026-06-22 22:42:50 KST  info   Record move success: migration id(1619) / record(9015) / 4232 -> 7145
2026-06-22 22:42:50 KST  error  Capture move validation failed: migration id(1620) - Record 9015 does not belong to source facility 4232 (current: 7145). It may have been moved by another operation.

추가 확인: 14일 retention 안에서 "Capture move validation failed" 메시지는 이 1건이 유일했다.

text
service:cupixvista-api-migration-worker "Capture move validation failed"  →  1 hit (now-14d)

이 점이 본 사건이 시스템적 race가 아니라 동일 record에 대해 두 건의 migration request가 동시에 생성된 일회성 입력 조건으로부터 비롯되었을 가능성을 시사한다. 다만 입력 조건이 어디서(사용자 더블 클릭 vs. 외부 시스템 재시도 vs. evergreen 측 중복 생성) 발생했는지는 본 로그만으로 단정할 수 없다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 동일 record(9015)에 대해 migration 1619와 1620 두 요청이 거의 동시에 enqueue되어, 1619 성공 직후 1620이 stale source_facility_id=4232로 실행됨. Defensive check가 의도대로 작동. Record move success: migration id(1619) / record(9015) / 4232 -> 7145Capture move validation failed: migration id(1620)가 같은 초(22:42:50 KST)에 기록. move_worker.rb:177-181의 방어 조건이 정확히 이 시나리오를 막도록 작성됨. Confirmed
H2 단일 migration 1620의 인자 자체가 잘못되어(source_facility_id 오타/오기) 처음부터 mismatch. 만약 그렇다면 1619가 동일 record를 4232→7145로 성공시키지 못했어야 함. 1619의 begin 로그(source record id(9015))와 success 로그가 facility 4232 시작을 확인. 1619가 4232에서 정상 시작 후 7145로 성공. 즉 22:42:14 KST 시점 record.facility_id는 분명히 4232였음. Rejected
H3 MoveWorker의 트랜잭션 내에서 다른 worker가 동일 record를 수정하여 발생한 dead-lock/concurrency 버그. 동일 record에 두 worker가 접근. 1619는 트랜잭션을 완전히 커밋(Record move success 로그)했고 1620은 트랜잭션 진입 전 단계인 validate_and_prepare_move에서 raise. 트랜잭션·락 충돌 아님. sidekiq_options queue: :migration 단일 큐에 직렬 처리 가정 시에도 두 job이 별개 record 요청처럼 보였기 때문에 직렬 실행 자체로는 막을 수 없음. Rejected
H4 Sidekiq의 자동 retry가 1619 job을 두 번 enqueue/실행해 두 번째 시도가 1620으로 보이는 것. 동일 record/동일 source facility. migration_id가 1619 → 1620으로 다르고, retry라면 동일 migration_id로 재실행됨. sidekiq_retries_exhausted도 메시지 형식이 Capture move retries exhausted로 달라 본 메시지(Capture move validation failed)와 구분됨. Rejected
H5 외부 dependency (DB replica lag, S3 등) 장애로 인한 일시적 stale read. Status board 조회 결과 dep:* 활성 인시던트 없음, MoveWorker는 primary write 후 즉시 record.facility_id를 다시 읽음. 1619 트랜잭션 commit과 1620 read 사이의 시간차는 충분(같은 초이지만 직렬 큐). Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 로그 레벨 재평가 (app/workers/move_worker.rb:76): 본 메시지는 데이터 무결성 측면에서 방어 코드가 정상 작동한 결과이며 사용자 입력/상류 플로우 이슈에 가깝다. 자동 알림과 status 페이지를 오염시키지 않도록 Cupix::Logger.errorCupix::Logger.warn 로 강등하는 것을 권장. (MEMORY.md의 "Assess error severity during RCA" 가이드와 일치.)
  • 동시에 MigrationOperation.check_move(... result: 'error') 호출은 그대로 두어 migration 상태가 사용자에게 명확하게 표시되도록 유지.

단기 개선 (1주 이내)#

  • Enqueue 시점에서 동일 record 중복 migration 차단: MigrationsController#move(app/controllers/api/v1/migrations_controller.rb:47-76)에서 MoveWorker.perform_async 호출 직전, 해당 source_record_id에 대해 in-flight 또는 미완료 migration(예: MigrationOperation 상태가 moving/pending)이 있는지 확인하고 있으면 4xx를 반환하는 방향을 검토. 또는 Evergreen 측에서 동일 source record에 대해 동시 migration 생성 자체를 제약.
  • MoveWorker 진입 시 record-level lock: validate_and_prepare_move 직후 Record.lock.find(...) 또는 advisory lock(record_id 기반)을 잡아, 두 번째 worker가 첫 번째가 끝날 때까지 대기하도록 직렬화. 본 사례에서는 첫 번째가 commit한 뒤 두 번째가 조건을 통과하지 못하게 되므로 동일한 결과가 나오지만, 로그/UX가 더 명확해진다.

장기 개선 (재발 방지)#

  • Migration request 생성 시 (source_record_id, status in [pending, moving])를 unique constraint로 강제 (Evergreen 또는 tesla 측 어느 쪽이든 단일 source of truth).
  • Migration UI에서 사용자 더블 클릭/중복 제출 방지(button disable + idempotency key).
  • Capture move validation failed와 같이 입력/race 기인 메시지를 metric으로 분리(migration.move.precheck_rejected)하여 알림 임계치를 별도로 운영.

Monitoring#

text
service:cupixvista-api-migration-worker "Capture move validation failed"
text
service:cupixvista-api-migration-worker @class:MoveWorker status:error
text
service:cupixvista-api-migration-worker "Record move success"
  • 위 쿼리들은 release dashboard timeseries widget에 그대로 사용 가능. monitor-only 문법(| stats, count by(...)) 미사용.
  • 재발 추세: "Capture move validation failed" 가 일 단위로 1건을 넘기 시작하면 enqueue 측 중복 차단 단기 개선을 우선 적용한다.

Risk Assessment#

  • Risk level: low — 데이터 무결성에 영향 없음, 1회 발생, 방어 코드가 의도대로 동작.
  • 예상 복잡도: trivial (로그 레벨 강등) ~ standard (enqueue/락 기반 직렬화 추가).