ES /docs

Parent model is invalid: model(pointcloud)

RCA: Parent model is invalid: model(pointcloud)

Overview#

What Happened#

2026-07-13 20:12 KST, cupixworks-migration-worker 서비스(eu-central-1, tenant cupix)에서 facility 11162 를 migration id 1885 로 export 하는 도중 MigrationExportOperation#create_model_data 가 pointcloud 4건에 대해 Parent model is invalid: model(pointcloud) error 로그를 출력했다. 예외가 raise 되지는 않았고 export 는 계속 진행되었지만, 해당 pointcloud 들은 parent_id: nil 로 exported JSON 에 기록되어 import 측에서 문제가 될 수 있는 상태다.

Quick Facts#

Field Value
exception.class MigrationExportOperation (로그 이벤트, exception 아님)
exception.message Parent model is invalid: model(pointcloud)
top_frame app/operations/migration_export_operation.rb:519
deploy production-eu-central-1-20260713T1002Z0-dcf0aa79-cupixworks
env production / eu-central-1
tenant cupix

Affected Teams#

Team / Domain Error Count Impact
cupixworks-migration-worker (facility 11162, migration 1885) 4 Export 는 완료되었으나 exported pointcloud 4건이 parent_id: nil 상태로 기록됨 — 이후 import 시 orphan 처리 필요

Timeline#

  1. 2026-07-13 20:12:53 KSTExportWorker#perform 이 migration id 1885 시작 (Database export begin - migration id(1885) retry_limit(5))
  2. 2026-07-13 20:12:53 KST — facility 11162 의 record/pointcloud 를 읽는 단계에서 MigrationExportOperation#create_model_data('pointcloud', pointclouds) 가 uploaded pointcloud 4건을 처리
  3. 2026-07-13 20:12:53 KST — 각 pointcloud 에서 parent_id.nil? && parent.present? 조건이 성립하여 error 로그 4건 발생

Error Log#

Datadog Logs

text
Parent model is invalid: model(pointcloud)

Impact#

  • Service: cupixworks-migration-worker
  • 발생 횟수: 4
  • 최초 발생: 2026-07-13 20:12 KST
  • 최근 발생: 2026-07-13 20:12 KST
  • 단일 migration(id=1885, facility=11162, tenant=cupix)에 국한. 예외가 아닌 error-level 로그이므로 job 은 실패하지 않지만, exported JSON 에 4개의 uploaded pointcloud 가 parent: "cluster", parent_id: nil 로 기록되어 import 측 무결성에 잠재적 영향.

Root Cause Summary#

MigrationExportOperation::MODEL_TREEpointcloud 의 parent 를 cluster, foreign_key 를 cluster_id 로 고정 정의하고 있다 (migration_export_operation.rb:127). 그러나 실제 Pointcloud 모델은 pointcloud_typeuploaded 인 것과 3d_reconstructed 인 것 두 종류가 있고, 파일 상단의 주석(라인 26 “pointcloud(customer upload)” 은 level 아래) 및 라인 50 “pointcloud (3d reconstruction)” 은 cluster 아래)이 이를 명시적으로 구분한다. read_records (라인 319) 는 Pointcloud.where(record_id: record_ids).where(pointcloud_type: 'uploaded') 로 record 에 직접 매달린 uploaded pointcloud 를 조회하는데, 이들은 cluster_id 가 nil 이다. 이 rows 를 create_model_data('pointcloud', pointclouds) 로 넘기면 라인 485 에서 parent_id = row['cluster_id'] → nil 이 되고, 라인 518-520 의 “wrong parent model definition” 가드에 걸려 error 로그가 남는다. 즉 MODEL_TREE 스키마가 pointcloud 의 두 가지 상위 관계(record 또는 cluster)를 표현하지 못하는 것이 근본 원인이다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/export_worker.rb#perform (Sidekiq) — 로그의 class: "ExportWorker", function: "perform" 로 확인
  • Facility 하위 데이터 export 중 MigrationExportOperation#read_records 호출
  • read_records 에서 uploaded pointcloud 조회 후 create_model_data('pointcloud', pointclouds) 호출
  • create_model_data 가 각 row 에 대해 MODEL_TREE 의 parent/foreign_key 를 참조해 parent_id 를 뽑는 지점에서 nil 검출

MODEL_TREE 상 pointcloud 정의:

app/operations/migration_export_operation.rb:126-128ruby
    cluster: { order: 1251, parent: 'capture', foreign_key: 'capture_id' },
    pointcloud: { order: 1252, parent: 'cluster', foreign_key: 'cluster_id' },
    pointcloud_permission: { order: 1253, parent: 'pointcloud', foreign_key: 'pointcloud_id' },

read_records 는 uploaded 타입을 명시적으로 조회한다 — 이 pointcloud row 들은 record_id 는 있지만 cluster_id 는 nil:

app/operations/migration_export_operation.rb:319-321ruby
    pointclouds = Pointcloud.where(record_id: record_ids).where(pointcloud_type: 'uploaded').where.not(cycle_state: %w[trashed purged]).order(:id).select('*').map(&:attributes)
    pointcloud_ids = pointclouds.map { |pointcloud| pointcloud['id'] }
    pointcloud_resources = fetch_resources('pointcloud', pointcloud_ids)

create_model_data 는 MODEL_TREE 만 참조하므로 row 의 실제 컬럼 값과 무관하게 foreign_key='cluster_id' 를 뽑는다:

app/operations/migration_export_operation.rb:477-520ruby
    parent = MODEL_TREE[model_name.to_sym]&.dig(:parent)
    foreign_key = MODEL_TREE[model_name.to_sym]&.dig(:foreign_key)

    # rows are hash type of to be exported model
    rows.each do |row|
      # Remove storage_option from sys column if present to allow target environment to set its own storage
      sanitize_sys_column!(row, model_name)

      parent_id = foreign_key ? row[foreign_key.to_s] : nil
      # ...
      # wrong parent model definition in model tree
      if parent_id.nil? && parent.present?
        Cupix::Logger.error("Parent model is invalid: model(#{model_name})", class: self.class.name, method: __method__)
      end

기대 동작: uploaded pointcloud 는 record 에 매달려 있으므로 parent='record', parent_id=row['record_id'] 로 export 되어야 함. 실제 동작: MODEL_TREE 가 pointcloud 를 “cluster 자식” 하나로만 정의해, uploaded 케이스에서 parent_id=nil 이 되어 “wrong parent model definition” 가드가 유효 데이터를 잘못 flag 하고, exported JSON 에는 부모 없는 pointcloud entry 가 남는다.

Pointcloud 모델도 두 타입을 명시:

app/models/pointcloud.rb:46-59ruby
  ALLOWED_POINTCLOUD_TYPE = %w[uploaded 3d_reconstructed].freeze
  # ...
  validates :record_id, presence: true, on: [:create]
  validates :pointcloud_type, presence: true, inclusion: { in: ALLOWED_POINTCLOUD_TYPE, message: "Invalid pointcloud_type, use one of #{ALLOWED_POINTCLOUD_TYPE.join(' | ')}" }

  scope :reconstructed, -> { where(pointcloud_type: '3d_reconstructed') }
  scope :uploaded_pointcloud_type, -> { where(pointcloud_type: 'uploaded') }

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-migration-worker status:error @environment:production "Parent model is invalid: model(pointcloud)"

에러 로그(4건 동일):

json
{
  "timestamp": "2026-07-13 20:12:53 KST",
  "status": "error",
  "message": "Parent model is invalid: model(pointcloud)",
  "class": "MigrationExportOperation"
}

동일 시각(20:12:53 KST) migration 1885 흐름 (쿼리: service:cupixworks-migration-worker @environment:production "migration id"):

text
20:12:53 INFO  Database export begin -  migration id(1885) retry_limit(5)
20:12:53 INFO  Database export -  migration id(1885): proceed({:last_step=>"initialize", :facility_id=>11162, ...})
20:12:53 INFO  Database export -  migration id(1885): read record(record, record permission, spacetime, pointcloud, pointcloud permission) for facility id(11162)
20:12:53 INFO  Database export -  migration id(1885): read capture(capture, capture permission, capture_type, video, cluster, pointcloud, pointcloud permission) for record ids([5701])
20:12:53 ERROR Parent model is invalid: model(pointcloud)   x4
  • migration id: 1885, facility_id: 11162, record_ids: [5701], tenant: cupix, region: eu-central-1
  • error 는 read_records (record 하위 pointcloud) 단계 로그 다음에 발생 — uploaded pointcloud 처리 경로와 일치. read_captures 도 pointcloud 를 다루지만 pointcloud_type: '3d_reconstructed' 필터가 있어(라인 366) capture_id/cluster_id 가 채워진 rows 만 오므로 이 에러의 소스일 수 없다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 MODEL_TREE 가 pointcloud 의 uploaded/3d_reconstructed 두 부모 관계를 표현하지 못해 uploaded pointcloud 의 cluster_id=nil 이 “wrong parent” 가드에 걸린다 코드: read_records 라인 319 가 uploaded 만 조회, MODEL_TREE 라인 127 이 parent=cluster 로 하드코딩, 파일 상단 주석(라인 26 vs 50)이 두 관계를 구분. 로그: 에러 4건이 read_records 단계 직후 발생, create_resource_data warn 도 4건 → 동일 batch 의 pointcloud 4건과 일치 Confirmed
H2 read_captures 의 3d_reconstructed pointcloud 가 cluster_id 없이 저장되어 발생 코드적으로 같은 error path 를 공유 라인 366 에서 pointcloud_type: '3d_reconstructed' 로 필터되어 cluster_id 가 있어야 조회 대상이 됨; DB validation 상 3d_reconstructed 는 cluster_id presence 를 요구 (모델 스코프로 추정); 에러가 read capture 로그보다 앞선 read record 단계에서 발생 Rejected
H3 Pointcloud row 가 실제로 손상되어 cluster_id 컬럼이 예상 밖으로 nil 로그만으로는 배제 불가 — DB 확인 필요 4건 동시 발생 & tenant/facility 격리 → 스키마 레벨 이슈에 부합, row 단위 손상 시나리오는 4건 동시 확률이 낮음 Inconclusive (needs DB verify)
H4 외부 dependency 장애 (status-board svc:cupixworks-migration-worker::unknown) 최근 resolved 인시던트 2건 존재 (2026-07-13 04:07, 2026-07-10 05:13) active 인시던트 없음, 원인 서비스도 migration-worker 자신, 에러 메시지가 dependency timeout/5xx 계열 아님 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/operations/migration_export_operation.rbcreate_model_data (라인 470-536) 에서 model_name == 'pointcloud' 이고 row['cluster_id'] 가 nil 인 경우 uploaded 케이스로 판단하여 parent='record', foreign_key='record_id' 로 재설정하도록 분기. 또는 sanitize_sys_column! 옆에 pointcloud 전용 override helper 를 두어 MODEL_TREE 참조를 우회. (구현 라인 5-10 정도)
  • 로그 레벨은 우선 그대로 두되, "wrong parent" 가드에는 실제로 스키마 정의가 잘못된 경우와 “정상 uploaded pointcloud” 케이스를 분리하는 조건을 추가할 것 — 후자는 error 가 아님.

단기 개선 (1주 이내)#

  • MODEL_TREE 스키마를 parent 하나가 아닌 배열/조건 리스트로 확장하여 “model + row 조건 → parent” 매핑을 표현. 예: pointcloud 는 pointcloud_type=='uploaded' 이면 record, 그 외에는 cluster.
  • Export 결과 JSON validation 단계 추가: parent_id: nil 인 entry 를 export 종료 직전에 카운트해서 0 이 아니면 job 실패 처리 — 이런 케이스가 무성 무결성 이슈를 만들지 않도록.
  • Import 측(MigrationImportOperation) 에서 parent_id: nil 처리 로직을 확인하고, 현재 어떻게 다루는지 문서화.

장기 개선 (재발 방지)#

  • Pointcloud 처럼 다중 부모를 갖는 polymorphic-ish 모델을 위한 일반화된 tree 표현. Rails polymorphic association 을 그대로 반영하는 export DSL 을 검토.
  • Migration export/import 왕복 테스트 (integration test) 를 uploaded pointcloud 가 포함된 facility 로 확장 — 현재 회귀 커버리지가 부족했음을 시사.

Monitoring#

  • error 발생 시 migration_id, facility_id, tenant 를 로그 payload 에 포함하도록 Cupix::Logger.error 콜을 확장 (현재는 model 이름만 있음) — 추적 용이성 향상.

Datadog 쿼리 예시:

text
service:cupixworks-migration-worker status:error "Parent model is invalid"

시계열 카운트(release dashboard timeseries widget 용):

text
count:logs("service:cupixworks-migration-worker status:error \"Parent model is invalid\"").index("*").rollup("count").by("environment")

Risk Assessment#

  • Risk level: medium — 데이터 export 자체는 계속 성공하지만 exported JSON 이 parent_id: nil 를 포함해 import 측에서 orphan 처리에 따라 조용한 데이터 손실 가능성.
  • 예상 복잡도: standard — MODEL_TREE 를 확장하거나 pointcloud 특수 분기를 추가하는 국지적 수정. Import 측 동작 확인이 병행 필요.