ES /docs

Api::V1::VideosController#upload_candidates (avg 1278ms, max 1438ms)

RCA: Api::V1::VideosController#upload_candidates Latency

Overview#

What Happened#

2026-05-26 09:56~12:06 UTC 사이에 cupixworks-api 서비스의 Api::V1::VideosController#upload_candidates 엔드포인트에서 평균 1278ms, 최대 1438ms의 응답 지연이 발생했다. 동일 시간대 14건의 요청 중 5건이 500ms를 초과했으며, 최악의 경우 2421ms에 달했다. HTTP 에러는 발생하지 않았고 모두 200 OK로 응답했지만, 사용자 체감 성능에 영향을 주는 수준의 latency였다.

Quick Facts#

Field Value
resource_name Api::V1::VideosController#upload_candidates
top_frame app/repositories/video_repository.rb:232
env production, us-west-2
avg_duration 1278ms
max_duration 1438ms (클러스터 기준), 2421ms (같은 시간대 전체 기준)

Affected Teams#

Team / Domain Error Count Impact
ellisdon (us-west-2) 1 1115ms 응답 — 53건 결과 반환 시 serialization 544ms
evergreenconst (us-west-2) 1 2421ms 응답 — 167건 결과 반환 시 serialization 1718ms
decasult (us-west-2) 8 452~1436ms — 폴링 반복 중 DB 쿼리 지속적으로 ~400ms

Timeline#

  1. 2026-05-26 09:56:39Zellisdon 팀 요청: DB 653ms + serialization 544ms = 1115ms (53건 반환)
  2. 2026-05-26 11:14:51Zevergreenconst 팀 요청: DB 978ms + serialization 1718ms = 2421ms (167건 반환)
  3. 2026-05-26 12:02~12:12Zdecasult 사용자 8회 연속 폴링, DB 시간 지속적으로 394~436ms
  4. 2026-05-26 12:06:28Z — 1436ms 응답 (DB 397ms + 미설명 ~1000ms gap)
  5. 2026-05-27 — RCA 분석 수행

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::VideosController#upload_candidates",
  "service": "cupixworks-api",
  "occurrences": 2,
  "avg_ms": 1278,
  "max_ms": 1438,
  "sample_trace_id": "1014089133148918977"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 2 (클러스터 기준), 5건 >500ms (같은 시간대)
  • 최초 발생: 2026-05-26T09:56:36.809Z
  • 최근 발생: 2026-05-26T12:06:26.952Z

Root Cause Summary#

upload_candidates 엔드포인트의 지연은 두 가지 주요 원인에 의해 발생한다: (1) VideoRepository#upload_candidates에서 readable_facility_ids(fresh: true) 호출이 매 요청마다 캐시를 우회하여 복잡한 permission 쿼리를 실행하고, 이어서 default_joins + permission_joins로 최대 19개의 JOIN이 포함된 SQL을 생성하여 DB 시간이 400978ms에 달한다. (2) 결과 건수가 많을 때(53167건) serialization 과정에서 VideoSerializerupload_url 속성이 resources association을 개별 로드(N+1)하고, 각 video에 대해 S3 presigned URL을 생성하여 serialization 시간이 최대 1718ms까지 증가한다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/videos_controller.rb:21
  • Repository 호출: app/repositories/video_repository.rb:232 (upload_candidates 메서드)
  • Permission 쿼리: readable_facility_ids(fresh: true) — 캐시 우회, 매번 DB 조회
  • Capture scope 조합: WHERE record_id IN (...) OR facility_id IN (...) + eager_load(:capture_type) + untrashed cascade
  • Elasticsearch 검색: terms: { 'capture.id': _capture_ids } — 대량 ID 배열 전달
  • Permission JOIN: video_repository.rb:59-229 — 11개 permission subquery LEFT JOIN
  • Serialization: VideoSerializerupload_url 속성에서 N+1 + S3 presigned URL 생성
app/controllers/api/v1/videos_controller.rb:21-33ruby
def upload_candidates
  video_query_option = Cupix::QueryOption::Video.new(get_query_option(enable_current_team: false), params)
  videos = repository_instance.upload_candidates(video_query_option, {
    current_user: current_user,
    workspace_id: params[:workspace_id]
  })

  render_api Renderable.new({
    search_result: videos,
    is_collection: true,
    serializer_option: @serializer_option
  })
end
app/repositories/video_repository.rb:232-268 (simplified)ruby
def upload_candidates(query_option, options = {})
  # 1. Permission IDs - fresh: true bypasses cache
  _record_ids = current_user.directly_accessible_record_ids
  _facility_ids = current_user.readable_facility_ids(fresh: true)

  # 2. Capture scope with multiple JOINs
  captures = ::Capture.where(record_id: _record_ids)
    .or(::Capture.where(facility_id: _facility_ids))
    .upload_candidate
    .created_on_app  # eager_load(:capture_type) - LEFT OUTER JOIN
    .untrashed       # cascades parent (Record) join

  # 3. Pluck all capture IDs into memory
  _capture_ids = captures.pluck(:id)

  # 4. Elasticsearch query with large terms array
  # 5. Reload with 19-JOIN permission query
  # 6. Serialize with N+1 resources + S3 presigned URLs
end
app/repositories/video_repository.rb:27-56 (default_joins - 8 LEFT JOINs)ruby
def self.default_joins(videos)
  videos
    .joins(:facility, :capture)
    .joins("LEFT JOIN records ON ...")
    .joins("LEFT JOIN cameras ON ...")
    .joins("LEFT JOIN workspaces ON ...")
    .joins("LEFT JOIN levels ON ...")
    .joins("LEFT JOIN capture_types ON ...")
    .includes(:storage)
end

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-api @http.url_details.path:"/api/v1/videos/upload_candidates" @duration:>500000000

핵심 로그 — 가장 느린 요청 (evergreenconst, 167건 반환):

json
{
  "timestamp": "2026-05-26T11:14:51.348Z",
  "duration_ms": 2420.82,
  "db_ms": 978.36,
  "serialization_ms": 1718,
  "total_entries": 167,
  "team": "evergreenconst",
  "region": "us-west-2",
  "host": "ip-10-1-144-228",
  "status_code": 200
}

DB 시간이 높은 요청 (ellisdon, 53건):

json
{
  "timestamp": "2026-05-26T09:56:39.414Z",
  "duration_ms": 1114.65,
  "db_ms": 652.93,
  "serialization_ms": 544,
  "total_entries": 53,
  "team": "ellisdon",
  "region": "us-west-2"
}

미설명 latency gap 요청 (decasult, 1건 반환인데 1436ms):

json
{
  "timestamp": "2026-05-26T12:06:28.809Z",
  "duration_ms": 1436.47,
  "db_ms": 396.77,
  "serialization_ms": 11,
  "total_entries": 1,
  "team": "decasult",
  "region": "us-west-2"
}

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 readable_facility_ids(fresh: true) 캐시 우회로 매 요청마다 복잡한 permission 쿼리 실행 → DB 시간 증가 video_repository.rb:238에서 fresh: true 명시적 전달. 결과 1건인 decasult도 DB 394~436ms 소요 Confirmed
H2 19-JOIN permission query가 결과 수와 무관하게 기본 비용이 높음 decasult (1건 반환)도 DB 394ms. 8회 반복 모두 일관된 높은 DB 시간 Confirmed
H3 Serialization의 N+1 (resources + S3 presigned URL)이 대량 결과에서 지연 유발 167건 → 1718ms serialization, 53건 → 544ms (선형 비례). VideoSerializer에서 resources eager-load 누락 확인 1건 반환 시 serialization 10~14ms로 무시할 수준 Confirmed (대량 결과 시)
H4 네트워크 지연 또는 cross-region 호출이 원인 모든 느린 요청이 us-west-2 내부에서 발생. eu-central-1 요청은 상대적으로 빠름 (92~975ms) Rejected
H5 Ruby GC pause가 미설명 latency gap의 원인 12:06:28 요청에서 DB 397ms + serialization 11ms인데 총 1436ms (약 1000ms gap) 로그에서 GC 정보 확인 불가. 가능성은 있으나 증명 불가 Inconclusive
H6 Elasticsearch terms 쿼리의 대량 ID 배열이 병목 _capture_ids 배열이 클 수 있음 (captures.pluck(:id)) DB 시간에 ES 쿼리가 포함되어 있을 가능성이 있으나, 로그에서 ES 시간 분리 불가 Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • app/repositories/video_repository.rb:238readable_facility_ids(fresh: true)에서 fresh: true 제거 또는 TTL이 짧은 캐시 사용으로 변경. 매 요청마다 캐시를 우회할 이유가 없다면, 기존 캐시 메커니즘을 활용해야 한다.
  • VideoSerializer에서 upload_url 속성 생성 시 사용하는 resources association을 default_joins에서 .includes(:resources) 로 eager-load 추가 (video_repository.rb:27-56).

단기 개선 (1주 이내)#

  • permission_joins 로직(11개 LEFT JOIN subquery)을 materialized view 또는 사전 계산된 permission 테이블로 대체하여 JOIN 수 감소.
  • upload_candidates 결과에 대해 pagination limit 적용 (현재 per_page=300). 167건 serialization에 1.7초가 소요되므로, 최대 50건으로 제한하고 cursor-based pagination 도입 검토.
  • S3 presigned URL 생성을 batch 또는 lazy 방식으로 변경 — 클라이언트가 실제 업로드 시점에 개별 요청하도록 분리.

장기 개선 (재발 방지)#

  • Permission 시스템을 읽기 최적화된 별도 서비스/캐시 레이어로 분리. 현재 매 API 호출마다 permission JOIN을 수행하는 구조는 확장성 한계가 명확하다.
  • upload_candidates 엔드포인트의 SLO 설정 (P95 < 500ms) 및 모니터링 대시보드 구축.
  • Capture scope 쿼리 최적화 — record_id IN (...) + facility_id IN (...) 대신 permission 기반 scope을 DB 레벨에서 사전 필터링하는 방식 검토.

Monitoring#

  • upload_candidates P95/P99 latency alert: avg(last_5m):p95:trace.rack.request{resource_name:api::v1::videoscontroller_upload_candidates,env:production} > 1000
  • DB time 별도 추적: avg(last_5m):trace.active_record.sql{resource_name:api::v1::videoscontroller_upload_candidates} > 500
  • Serialization time이 1초 초과 시 alert 설정

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard — fresh: true 제거와 includes(:resources) 추가는 단순하나, permission JOIN 리팩토링은 광범위한 영향도 검토 필요