ES /docs

CaptureRepository#show N+1 query — DB contention

RCA: CapturesController#download_multiple_resource Latency (3100ms)

Overview#

What Happened#

2026-06-04 09:46 KST에 cupixworks-api 서비스의 Api::V1::CapturesController#download_multiple_resource 엔드포인트에서 3100ms의 응답 지연이 발생했다. 해당 요청은 capture 707591의 processing_options 리소스 다운로드였으며, 동시에 해당 capture의 preprocessor 처리 완료 및 skat master 호출이 진행되는 high-load 시점에 발생했다.

Quick Facts#

Field Value
resource_name Api::V1::CapturesController#download_multiple_resource
top_frame app/controllers/concerns/multiple_resourcable_controller.rb:20
env production, us-west-2
avg_duration 3100ms
max_duration 3100ms

Timeline#

  1. 2026-06-04 09:46:10 KSTdownload_multiple_resource 요청 발생 (3100ms latency, APM trace 3203445943561624117)
  2. 2026-06-04 09:46:14 KST[302] GET /api/v1/captures/707591/resources/processing_options/download 완료 로그 기록
  3. 2026-06-04 09:46:54 KST — 동일 capture 707591에 대해 preprocessor_agent_finished, CreateCaptureJob, skat master 호출 등 대량 처리 작업 동시 진행

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::CapturesController#download_multiple_resource",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 3100,
  "max_ms": 3100,
  "sample_trace_id": "3203445943561624117"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-06-04 09:46 KST
  • 최근 발생: 2026-06-04 09:46 KST

Root Cause Summary#

download_multiple_resource 엔드포인트는 단순히 S3 presigned URL로 302 redirect하는 작업이지만, set_capture before_action이 CaptureRepository.show를 호출하며 15개의 권한 테이블 LEFT JOIN과 7개의 INNER JOIN을 포함한 매우 무거운 SQL 쿼리를 실행한다. 이 쿼리가 capture 707591의 처리 파이프라인(preprocessor 완료, CreateCaptureJob, skat master 호출)과 동시에 실행되면서 DB 경합으로 인해 3100ms까지 응답 시간이 증가했다. 메트릭 데이터에 따르면 해당 시간대 평균 응답이 1.1-1.6초로 전반적으로 높았으며, 이 요청은 P99 outlier에 해당한다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/captures_controller.rb:17before_action :set_capture
  • set_capturerepository_instance.show(params[:id])를 호출
  • BaseRepository.showCaptureRepository.default_joins + CaptureRepository.permission_joins
  • set_multiple_resource에서 @model.resources.where(kind:).or(...).first 추가 쿼리
  • Action: multiple_resourcable_controller.rb:20@resource.download_urlpresigned_url(:get, ...)
app/controllers/api/v1/captures_controller.rb:140-142ruby
def set_capture
  @model = repository_instance.show(params[:id])
end
app/repositories/capture_repository.rb:256-276ruby
def self.default_joins(record)
  record.includes(:reviewers, :storage).joins(:level, :record, :capture_type, :facility, :workspace, :team, :user).select('
    captures.*,
    records.note AS record_note,
    records.captured_at AS record_captured_at,
    levels.name AS level_name,
    workspaces.name AS workspace_name,
    facilities.name AS facility_name,
    facilities.key AS facility_key,
    users.firstname AS user_firstname,
    users.lastname AS user_lastname,
    teams.name AS team_name,
    teams.domain AS team_domain,
    facilities.cycle_state AS applied_cycle_state,
    capture_types.name AS capture_type_name,
    capture_types.method AS capture_type_method,
    capture_types.material AS capture_type_material,
    capture_types.creation_platform AS capture_type_creation_platform,
    capture_types.migrated_from AS capture_type_migrated_from
  ')
end
app/repositories/capture_repository.rb:279-316ruby
def self.permission_joins(record, current_user, select: nil, review_id: -1, **kwargs)
  # 15개의 LEFT JOIN subquery로 review, capture, record, facility,
  # workspace, team 수준의 user/group/system_group 권한을 모두 조회
  # MAX(GREATEST(...)) 로 applied_permission 계산
  record.joins("
    LEFT JOIN (...) AS review_public_permissions ...
    LEFT JOIN (...) AS review_user_permissions ...
    LEFT JOIN (...) AS review_group_permissions ...
    LEFT JOIN (...) AS capture_user_permissions ...
    LEFT JOIN (...) AS record_user_permissions ...
    LEFT JOIN (...) AS record_group_permissions ...
    LEFT JOIN (...) AS record_system_group_permissions ...
    LEFT JOIN (...) AS facility_user_permissions ...
    LEFT JOIN (...) AS facility_group_permissions ...
    LEFT JOIN (...) AS facility_system_group_permissions ...
    LEFT JOIN (...) AS workspace_user_permissions ...
    LEFT JOIN (...) AS workspace_group_permissions ...
    LEFT JOIN (...) AS team_user_permissions ...
    LEFT JOIN (...) AS team_group_permissions ...
    LEFT JOIN (...) AS team_system_group_permissions ...
  ").group('id').select(_select).where(...)
end

Failure point는 특정 코드 결함이 아니라, 단순 redirect 작업에 비해 과도하게 무거운 set_capture 쿼리가 DB 부하 시점에 병목이 되는 구조적 문제이다.

app/controllers/concerns/multiple_resourcable_controller.rb:20-26ruby
def download_multiple_resource
  if @resource.revision == 0
    render_json 404
  else
    redirect_to @resource.download_url, allow_other_host: true
  end
end
app/models/concerns/storagable/resource.rb:185-205ruby
def download_url(opts = {})
  ver = opts[:ver] || self.revision
  raise Cupix::Errors::Resource.new(code: 'ENT10011', reason: "Resource does not uploaded: #{ver}") if ver.zero?
  filename = opts[:filename].presence || self.name
  case opts[:distribution]
  when 'cloudfront'
    _rcd = CGI.escape("attachment; filename=#{filename}")
  else
    if !opts[:exp].blank?
      exp = opts[:exp]
    else
      exp = 3.hours.to_i
    end
    self.object(ver).presigned_url(:get, expires_in: exp, response_content_disposition: "filename=#{CGI.escape(filename) rescue nil}")
  end
end

Log Evidence#

다음 Datadog 쿼리로 해당 시간대의 동일 엔드포인트 요청을 확인:

text
service:cupixworks-api "CapturesController#download_multiple_resource"
Time: 2026-06-04T00:44:00Z to 2026-06-04T00:48:00Z

해당 시간대에 capture 707591 관련 요청 확인:

text
[302] GET /api/v1/captures/707591/resources/processing_options/download (Api::V1::CapturesController#download_multiple_resource)
timestamp: 2026-06-04 09:46:14 KST

동일 trace ID로 발견된 동시 진행 작업:

text
service:cupixworks-api trace_id:3203445943561624117
Time: 2026-06-03T00:00:00Z to 2026-06-04T06:00:00Z
json
{"timestamp": "2026-06-04 09:46:54", "status": "info", "message": "[Capture] processing_preprocessor_agent_finished", "function": "log_event"}
{"timestamp": "2026-06-04 09:46:54", "status": "info", "message": "Sending message to https://sqs.us-west-2.amazonaws.com/.../cupix-tesla-ece-arm: {:job=>{:id=>1103257}...}", "class": "CreateCaptureJob", "function": "send_message"}
{"timestamp": "2026-06-04 09:46:54", "status": "info", "message": "skatmaster is invoked for capture 707591. job id: 1103257", "class": "Capture", "function": "run_skat_master"}

메트릭 데이터 (최근 2시간, 30초 간격):

text
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::capturescontroller_download_multiple_resource}

초기 시간대(인시던트 전후): 평균 0.93-1.59초 안정화 후: 평균 0.04-0.08초

이는 DB 부하가 높은 시간대에 해당 endpoint의 응답이 20-40배 느려짐을 확인한다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 복잡한 permission_joins SQL이 DB 부하 시 병목 15개 LEFT JOIN + 7개 INNER JOIN 구조 확인 (capture_repository.rb:279-470). 메트릭에서 동시간대 평균 1.1-1.6초로 전반적 지연 확인. 동일 trace에서 대량 처리 작업 동시 진행 확인. Confirmed
H2 S3 presigned URL 생성 지연 presigned_url은 로컬 서명 연산으로 네트워크 호출 불필요 (AWS SDK는 credential 캐싱). presigned URL은 SDK 로컬 연산이므로 ms 단위 소요. 안정 시 40-80ms 응답은 S3 지연이 주 원인이 아님을 증명. Rejected
H3 set_multiple_resource의 리소스 조회 쿼리 지연 resources.where(kind:).or(resources.where(key:)).first는 추가 쿼리 (multiple_resourcable_controller.rb:148). 단순 WHERE + OR 쿼리로 단일 테이블 조회. 복잡도가 permission_joins 대비 매우 낮음. Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/controllers/api/v1/captures_controller.rb:17download_multiple_resource action을 set_capture before_action에서 제외하고, 경량화된 capture 조회 로직을 사용해야 한다.
  • 현재 set_capturerepository_instance.show(params[:id])를 호출하여 전체 permission_joins를 실행하는데, resource download는 capture 자체의 상세 정보가 필요 없으므로 Capture.find(params[:id]) 수준의 단순 조회 + 기본 권한 확인만으로 충분하다.

단기 개선 (1주 이내)#

  • download_multiple_resource, show_resource 등 리소스 접근 action에 대해 skip_permission: true 옵션을 활용한 경량 조회 경로를 도입하거나, set_capture에서 호출하는 permission_joins를 download 전용으로 간소화한 버전을 제공한다.
  • CaptureRepository.permission_joins의 15개 LEFT JOIN을 필요한 수준에 맞게 분리 (full permission check vs. read-only check).

장기 개선 (재발 방지)#

  • 리소스 다운로드 endpoint는 DB 부하와 무관하게 빠른 응답을 보장해야 하므로, presigned URL을 캐싱하거나 (resource revision이 변경되지 않는 한) CloudFront signed URL 경로로 전환하는 것을 검토한다.
  • default_joins + permission_joins 패턴을 action 유형에 따라 최적화된 쿼리 빌더로 리팩토링 — read-only 조회에는 전체 permission hierarchy가 불필요.

Monitoring#

  • download_multiple_resource 엔드포인트에 대한 P99 latency 알림 설정 (threshold: 1000ms)
  • DB 부하 시점에 해당 endpoint가 영향받는지 correlation 모니터링
text
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::capturescontroller_download_multiple_resource} > 1

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard — set_capture before_action의 except 목록에 action을 추가하고 경량 조회를 구현하는 수준