ES /docs

ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]

RCA: ActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=92228359 [WHERE panos.state != ?]

Overview#

What Happened#

cupixworks-api (tesla) 의 bulk annotation 생성 경로가 클라이언트가 보낸 pano_id::Pano.find 를 직접 호출한다. 해당 pano 가 존재하지 않거나 abandoned 상태이면 Statable::Panodefault_scope { where.not(state: :abandoned) } 때문에 조회 결과가 0건이 되어 ActiveRecord::RecordNotFound 가 발생한다. 2026-07-09 오전 (KST) 약 37분 동안 한 번의 버스트로 16건 기록되었다.

Quick Facts#

Field Value
exception.class ActiveRecord::RecordNotFound
exception.message Couldn't find Pano with 'id'=92228359 [WHERE panos.state != ?]
top_frame app/factories/concerns/bulkable_factory/annotation.rb:51
runtime Ruby on Rails (tesla)
env production

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (tesla) 16 bulk annotation 생성 요청이 404 로 실패. 존재하지 않거나 abandoned 된 pano 를 참조한 클라이언트에 한정

Timeline#

  1. 2026-07-09 06:30 KST — 최초 발생 (first_seen).
  2. 2026-07-09 07:07 KST — 최근 발생 (last_seen). 이후 재발 없음.
  3. 2026-08-04 — RCA 수행. Datadog 14일 retention 창 밖이라 로그 0건.

Error Log#

Datadog Logs

text
Couldn't find Pano with 'id'=92228359 [WHERE `panos`.`state` != ?]

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 16
  • 최초 발생: 2026-07-09 06:30 KST
  • 최근 발생: 2026-07-09 07:07 KST

Root Cause Summary#

bulk annotation 생성 경로가 클라이언트 입력 pano_id 를 검증 없이 ::Pano.find 로 조회한다. Statable::Panodefault_scope { where.not(state: :abandoned) } 를 적용하므로, pano 가 실제로 없거나 abandoned 상태이면 find 가 0건 결과에서 ActiveRecord::RecordNotFound 를 raise 한다. 이 예외는 bulkable_factory/annotation.rbprepare_items 안에서 발생하는데, 이 지점은 어떤 rescue 로도 감싸져 있지 않다. tesla 컨트롤러 스택에는 rescue_from ActiveRecord::RecordNotFound 가 없어 Rails 기본 rescue_responses 매핑에 따라 HTTP 404 로 응답된다. 즉 서버 로직 결함이 아니라 클라이언트가 유효하지 않은(존재하지 않거나 이미 폐기된) pano 를 참조한 입력 오류다. 같은 기능의 단건 annotation 생성 경로는 이미 PanoRepository#show (Cupix::Errors::NotFound → 403) 로 정상 처리하고 있으므로, 실제 결함은 bulk 경로가 repository show 대신 raw find 를 쓰는 처리 방식 불일치뿐이다.

Technical Analysis#

Code Path#

  • Entry point: bulk annotation 라우트. config/routes.rb:177-179bulkable concern 이 put '' → action :bulk 로 매핑.
  • Api::V1::AnnotationsControllerAnnotationRepository#bulkAnnotationFactory#bulk!.

AnnotationFactoryBulkableFactory::Annotation 을 include 하며 자체 bulk! 를 정의한다. 이 안에서 prepare_items 를 호출한다.

app/factories/concerns/bulkable_factory/annotation.rb:11-33ruby
def bulk!(params = {}, **kwargs)
  self.created_uuids = []

  annotations_params = params[:annotations] || []
  raise Cupix::Errors::Parameter.new(code: 'ARG10000', reason: 'annotations is required') if annotations_params.blank?

  layer_cache = {}
  annotations_params.map { |a| a[:annotation_layer_id] }.compact.uniq.each do |layer_id|
    layer = ::AnnotationLayer.find(layer_id)
    raise Cupix::Errors::PermissionDenied.new(code: 'PERM10000', reason: 'Permission denied') unless layer.facility.creatable_by?(current_user)

    layer_cache[layer_id] = layer
  end

  prepared_items, invalid_items, items_meta = prepare_items(annotations_params, layer_cache)
  • Failure point: app/factories/concerns/bulkable_factory/annotation.rb:51.
app/factories/concerns/bulkable_factory/annotation.rb:50-51ruby
form_design = form_design_cache[annotation_params[:form_design_id]] ||= ::FormDesign.includes(:form_fields).find(annotation_params[:form_design_id])
pano = pano_cache[annotation_params[:pano_id]] ||= ::Pano.find(annotation_params[:pano_id])

::Pano.findStatable::Pano 의 default scope 를 그대로 받는다.

app/models/concerns/statable/pano.rb:8ruby
default_scope { where.not(state: :abandoned) }

이 scope 때문에 실제 쿼리는 SELECT ... FROM panos WHERE panos.id = ? AND panos.state != ? 형태가 되고, pano 가 없거나 abandoned 이면 0건 → findActiveRecord::RecordNotFound: Couldn't find Pano with 'id'=... [WHERE panos.state != ?] 를 raise 한다. 메시지의 [WHERE panos.state != ?] 조각이 바로 이 default scope 의 흔적이다.

예외는 rescue 없이 전파된다. AnnotationFactory#bulk! 는 이 호출을 감싸지 않고, 상위 BulkableFactory#bulk 래퍼는 StandardError 를 그대로 re-raise 한다.

app/factories/bulkable_factory.rb:81-87ruby
def bulk(params = {}, **kwargs)
  _results = self.bulk!(params, **kwargs)
rescue StandardError => e
  raise e
else
  _results
end

컨트롤러 스택에는 ActiveRecord::RecordNotFound 를 잡는 핸들러가 없다. ClientErrorControllerCupix::Errors::*, ActiveRecord::RecordInvalid, ValueTooLong 등만 rescue 한다.

app/controllers/concerns/client_error_controller.rb:7-31ruby
rescue_from Cupix::Errors::Unknown,
            Cupix::Errors::Resource,
            Cupix::Errors::Session,
            Cupix::Errors::Parameter,
            Cupix::Errors::Entity,
            Cupix::Errors::Billing,
            Cupix::Errors::InvalidState,
            Cupix::Errors::Siteinsights, with: :client_400_error

rescue_from ActionController::ParameterMissing,
            ActiveRecord::RecordInvalid, with: :invalid_parameter_400_error
# ... ActiveRecord::RecordNotFound 없음

따라서 Rails 기본 config.action_dispatch.rescue_responses (tesla 는 이 매핑을 override 하지 않음) 가 ActiveRecord::RecordNotFound 를 HTTP 404 로 렌더한다.

  • 기대 동작: 유효하지 않은 pano_id 는 4xx 클라이언트 오류로 처리되어야 한다.
  • 실제 동작: 404 로 응답되지만, Cupix::Errors::* 계열이 아니라 raw ActiveRecord 예외로 표면화되어 Error Tracking 에 집계된다.

대조군 (정상 처리): 단건 annotation 생성 경로는 raw find 대신 repository show 를 사용한다.

app/concerns/parameter/annotation.rb:34-35ruby
elsif params[:pano_id].present?
  self.model.annotatable = PanoRepository.new(current_user: self.current_user, review: self.review).show(params[:pano_id])

BaseRepository.show.first 로 nil 을 받으면 Cupix::Errors::NotFound 를 raise → not_found_403_error 로 매핑된다 (ActiveRecord::RecordNotFound 아님).

app/repositories/base_repository.rb:343-356ruby
model = query.merge(scope).first
# ...
if model.nil?
  if self.where(attrs).in_trash.present?
    raise Cupix::Errors::NotFound.new(code: 'ENT4000', reason: "#{current_class.name} not found")
  else
    raise Cupix::Errors::NotFound.new(code: 'ARG10002', reason: "#{current_class.name} not found")
  end
end

Log Evidence#

이 이슈는 Datadog Error Tracking (et: fingerprint) 항목이다. last_seen 이 2026-07-08 로 14일 retention 창 밖이라 로그 검색은 0건이다.

사용한 쿼리와 결과 (기준 시각 2026-08-04, now-14d):

text
service:cupixworks-api "Couldn't find Pano"            -> 0 logs
service:cupixworks-api "WHERE `panos`.`state`"          -> 0 logs
service:cupixworks-api "RecordNotFound" Pano            -> 0 logs
service:cupixworks-api "ActiveRecord::RecordNotFound"   -> 0 logs

ActiveRecord::RecordNotFound 는 Rails 가 404 로 매핑하고 애플리케이션 error 로그로 남기지 않기 때문에, retention 창 안이라 해도 status:error 로그로는 검색되지 않는다. 이는 위 검색이 모두 0건인 것과 일치한다.

APM span 도 확인했다. service:cupixworks-api status:error span 5000건 중 pano 관련은 3건뿐이며 모두 Pano already stitched (STAT10000, 다른 에러) 로, RecordNotFound span 은 없었다.

text
service:cupixworks-api status:error   -> pano 관련 span 3건, 전부 "Pano already stitched" (무관)

annotation 엔드포인트의 요청 로그는 retention 창 안에 존재한다 (now-14d): [500] 22건, [404] 52건. 다만 이 요청 로그에는 예외 클래스가 실려 있지 않아 이 특정 이슈와 직접 연결할 수는 없다 (uncertain -- 대표 메시지가 retention 밖이므로 요청 로그와의 1:1 매칭은 불가). 대표 메시지의 [WHERE panos.state != ?] 조각은 default_scope 에서 결정론적으로 생성되는 고정 형태이므로, id 값만 변하는 대표 샘플은 신뢰할 수 있다 (stale 아님).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 bulk annotation 경로의 raw ::Pano.find(pano_id) 가 존재하지 않거나 abandoned 된 pano 에서 RecordNotFound 를 raise, 컨트롤러에 핸들러 없어 404 로 표면화 bulkable_factory/annotation.rb:51 raw find + statable/pano.rb:8 default_scope where.not(state: :abandoned) (메시지의 [WHERE panos.state != ?] 와 정확히 일치); client_error_controller.rbRecordNotFound rescue 없음; ActiveRecord::RecordNotFound 로그 0건 (Rails 404, error 로깅 안 함) Confirmed
H2 단건 annotation 생성 (AnnotationFactory#create!) 이 원인 단건도 pano_id 를 받음 단건은 parameter/annotation.rb:35 에서 PanoRepository#show 사용 → nil 시 Cupix::Errors::NotFound (403), RecordNotFound 아님. 메시지의 [WHERE panos.state != ?] 는 raw find 경로에서만 나옴 Rejected
H3 soft-copied pano 표시 시 base_repository.rb:368 model.class.find(softcopied_from_id) 가 원인 동일하게 raw Pano.find + default_scope 이 경로는 pano show 성공 후 부모 조회에만 도달; 대표 발생 규모(단일 버스트 16건)와 bulk annotation 입력 패턴이 더 부합. 확정 증거 없음 (uncertain), H1 이 더 직접적 Rejected
H4 ES/DB 인프라 장애 등 외부 의존성 status-board scope svc:cupixworks-api::unknown, 이 클러스터 대상 active incident 없음 (recent 2026-07-29/30 은 last_seen 이후·무관) Rejected

Fix Recommendation#

즉시 조치 (Critical)#

없음. 서버 결함이 아니며, 이미 404 (클라이언트 오류) 로 응답되고 있다. 코드 변경 불필요.

단기 개선 (1주 이내)#

  • 처리 방식 일관화 (선택): bulkable_factory/annotation.rb:51 의 raw ::Pano.find(annotation_params[:pano_id]) 를, 단건 경로와 동일하게 PanoRepository#show 를 쓰거나 find_by(id:) + nil 체크로 바꿔 Cupix::Errors::Parameter/NotFound (4xx) 로 명시 매핑한다. 그러면 raw ActiveRecord::RecordNotFound 대신 tesla 표준 에러 코드로 표면화되어 Error Tracking 노이즈가 사라진다. form_design 조회(FormDesign...find) 도 같은 패턴이므로 함께 검토.
  • 프런트엔드 협의 필요: bulk 요청에 유효하지 않은 pano_id 가 섞여 들어오는 근본 원인(뷰어가 이미 abandoned 된 pano 를 참조)을 확인. 이 항목은 응답 계약 변경을 수반할 수 있어 자동 반영 대상에서 제외.

장기 개선 (재발 방지)#

  • bulk 입력 항목별 사전 검증(prepare 단계에서 유효하지 않은 pano_id 를 invalid_items 로 분류)으로 부분 실패를 허용하고, 존재하지 않는 참조 하나가 전체 요청을 404 로 실패시키지 않도록 한다.

Monitoring#

Error Tracking 이슈 재발 여부를 요청 로그로 근사 모니터링한다.

annotation bulk 엔드포인트의 404 발생 추이:

text
service:cupixworks-api "annotations" "[404]"

pano 관련 RecordNotFound 표면화 여부 (retention 창 안 재발 감지):

text
service:cupixworks-api "Couldn't find Pano with"

Risk Assessment#

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

Noise Verdict#

noise — 클라이언트가 존재하지 않거나 이미 abandoned 된 pano_id 로 bulk annotation 을 요청해 발생하는 입력 검증 실패이며 이미 404 로 정상 응답되므로 서버 코드 결함이 아니다.