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#
- 2026-06-22 22:42:14 KST —
Record move begin - migration id(1619) / source record id(9015) / target facility id(7145)(MoveWorker#perform,move_worker.rb:81) - 2026-06-22 22:42:14 KST — Record, Spacetime, Video, Capture, Cluster, Pointcloud, Mesh, EditingEntity 단계 완료 로그 기록
- 2026-06-22 22:42:26 KST — Pano, Review 단계 완료 (
MigrationMoveOperation#move_reviews!) - 2026-06-22 22:42:50 KST —
Record move success: migration id(1619) / record(9015) / 4232 -> 7145(move_worker.rb:115) - 2026-06-22 22:42:50 KST —
Capture move validation failed: migration id(1620) - Record 9015 does not belong to source facility 4232 (current: 7145).(move_worker.rb:76) — 본 인시던트
Error Log#
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:47—MigrationsController#move가 Evergreen에서 migration 상세를 가져와MoveWorker.perform_async로 enqueue. - Worker entry:
app/workers/move_worker.rb:63—MoveWorker#perform이 JSON 인자를 파싱. - Validation:
app/workers/move_worker.rb:73—validate_and_prepare_move호출. - Failure point:
app/workers/move_worker.rb:177-181—record.facility_id != source_facility_id이면Cupix::Errors::Parameterraise. - Rescue + log:
app/workers/move_worker.rb:74-79—Cupix::Errors::Parameter만 잡아 error 로그를 남기고MigrationOperation.check_move(... result: 'error')호출 후 return (no retry).
# 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
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 쿼리:
service:cupixvista-api-migration-worker "record(9015)"
핵심 로그(시간 순):
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건이 유일했다.
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 -> 7145와 Capture 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.error→Cupix::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#
service:cupixvista-api-migration-worker "Capture move validation failed"
service:cupixvista-api-migration-worker @class:MoveWorker status:error
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/락 기반 직렬화 추가).