Api::V1::RecordsController#show (avg 226589ms, max 226589ms)
RCA: Api::V1::RecordsController#show latency (226s)
Overview#
What Happened#
2026-07-01 11:15 KST 경 cupixworks-api의 Api::V1::RecordsController#show 요청 1건이 226.6초(226,589ms) 동안 지속되었다. 같은 시간대에 records, panos, captures, editings, jobs 관련 쓰기 API에서 Mysql2::Error::TimeoutError: Lock wait timeout exceeded 502 에러가 45건 이상 발생했으며, 이 latency 스파이크는 그 InnoDB 락 경합 이벤트 tail에 걸린 read 요청이다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::RecordsController#show |
| service | cupixworks-api |
| cluster_type | latency |
| avg_duration_ms | 226589 |
| max_duration_ms | 226589 |
| sample_trace_id | 3219083934536030476 |
| env | production, us-west-2 |
| tenant | cupix |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (records read) | 1 (>500ms slow trace) | 사용자가 record 상세 조회 시 최대 226초 대기 후 응답 수신 |
| cupixworks-api (panos/captures/editings write) | 45+ (502) | 락 대기 초과로 업로드/편집 상태 갱신 실패 |
Timeline#
- 2026-07-01 10:33 KST — svc:cupixworks-api::unknown 관련 클러스터 첫 발생 (incident
2026-07-01-svc-cupixworks-api--unknown-1). - 2026-07-01 10:51 KST — 첫
Mysql2::Error::TimeoutError: Lock wait timeout exceeded발생 (JobsController#update). - 2026-07-01 10:53–10:55 KST — Pano/Capture 쓰기 API에서 lock wait timeout이 대량으로 발생 (분당 5–7건).
- 2026-07-01 11:02 KST — 상기 svc-level 인시던트 자동 해소 처리 (
resolved_at). - 2026-07-01 11:11:31 KST — 문제의
RecordsController#show요청 시작 (추정: last_seen - 226.6s). - 2026-07-01 11:15:18 KST — 요청 226.6s 만에 종료 → 본 클러스터 감지.
- 2026-07-01 11:19 KST — 마지막 lock wait timeout (EditingsController#update). 이후 관련 502 로그 관찰되지 않음.
Error Log#
{
"resource_name": "Api::V1::RecordsController#show",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 226589,
"max_ms": 226589,
"sample_trace_id": "3219083934536030476"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1 (>500ms slow-trace 기준)
- 최초 발생: 2026-07-01 11:15 KST
- 최근 발생: 2026-07-01 11:15 KST
- 연관 502: 45+건 (lock wait timeout)
Root Cause Summary#
2026-07-01 10:51–11:19 KST 사이 MySQL 상에서 records/panos/captures/editings 계열 테이블에 대한 InnoDB 행/gap 락 경합이 지속되었다. 다수의 write API(PanosController#check_uploading, check_tile_uploading, stitched, check_mask_uploading, CapturesController#update_meta_by_key, EditingsController#update, JobsController#update) 가 트랜잭션 안에서 같은 record 계열 row들을 lock한 상태로 오래 유지되었고, RecordsController#show 는 BaseRepository.show 에서 permission_joins(default_joins(...)) — 즉 records/facilities/workspaces/teams 다중 JOIN + 14개의 permission LEFT JOIN — 쿼리를 실행하기 때문에 같은 row에 대한 read snapshot을 확보할 때 lock 대기에 걸렸다. 결과적으로 read 요청 1건이 innodb_lock_wait_timeout 근처(~50s)를 여러 번 대기하며 226.6초의 wall-clock latency로 관측되었다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/records_controller.rb:9—before_action :set_record set_record가 매 요청마다 repository 조회 수행:app/controllers/api/v1/records_controller.rb:63-65- 실제 조회는
BaseRepository.show안에서permission_joins+default_joins로 넘어감:app/repositories/base_repository.rb:306-343 - 조인 정의:
app/repositories/record_repository.rb:131-355 - Failure point:
app/repositories/record_repository.rb:143-354— 광범위한 permission JOIN 쿼리가 락 대기에 걸림.
Controller의 before_action은 write API가 아닌 read API(show)에서도 무조건 수행된다:
before_action :set_record, except: %i[create index untrash purge mock bulk_share bulk_unshare]
# ...
protected
def set_record
@model = repository_instance.show(params[:id])
end
BaseRepository#show는 요청 특성(read/write) 구분 없이 매번 permission JOIN을 다시 계산한다:
def self.show(key_or_id_or_model, current_user: nil, visibility: Cyclable.visibility[:UNTRASHED], review_id: nil, capture_id: nil, skip_permission: false)
# ...
query =
if skip_permission || current_user == ::User.unauthorized_user
where(attrs)
elsif current_class == ::Review || (review_id || capture_id).present?
permission_joins(default_joins(current_class), current_user, review_id: review_id || -1, capture_id: capture_id || -1).where(attrs)
elsif current_user.present?
permission_joins(default_joins(current_class), current_user).where(attrs)
else
raise Cupix::Errors::System.new(code: 'SYS30000', reason: 'current_user or review is required on Repository')
end
scope = current_class.visibility_scope(visibility)
model = query.merge(scope).first
RecordRepository.permission_joins가 실제 발행하는 JOIN — records 자체 조인 + 14개의 permission subquery LEFT JOIN — 이 lock 경합 대상 테이블(records, facility_permissions, record_permissions, ...) 을 동시에 스캔한다:
def self.default_joins(record)
record.includes(:storage).joins(:facility, :workspace, :team).select("
records.*,
workspaces.name AS workspace_name,
facilities.name AS facility_name,
facilities.key AS facility_key,
teams.qa_preference AS team_qa_preference,
facilities.qa_preference AS facility_qa_preference,
facilities.cycle_state AS applied_cycle_state
")
end
동일 시간대에 write API 쪽은 트랜잭션 안에서 records/panos/captures row를 lock 상태로 잡고 있어 위 read 쿼리가 스냅샷 획득에 걸리는 시간이 크게 늘어난다.
기대 동작 vs 실제 동작:
- 기대: 단순 record 상세 조회는 100ms 이내 응답.
- 실제: MySQL row lock 대기 → 요청 226.6초 유지.
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-api "Lock wait timeout"
2026-07-01T01:30:00Z → 2026-07-01T02:30:00Z
45건의 502 응답 중 대표 항목:
{
"timestamp": "2026-07-01 11:19:21 KST",
"status": "info",
"message": "[502] PATCH /api/v1/editings/1201005 (Api::V1::EditingsController#update)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
{
"timestamp": "2026-07-01 11:18:11 KST",
"status": "info",
"message": "[502] PUT /api/v1/panos/90887557/check_uploading (Api::V1::PanosController#check_uploading)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
{
"timestamp": "2026-07-01 10:53:16 KST",
"status": "info",
"message": "[502] PUT /api/v1/captures/724756/meta/prop (Api::V1::CapturesController#update_meta_by_key)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
같은 시간대에 Api::V1::RecordsController#show는 정상 응답(200)을 계속 반환하고 있었다 (아래 예):
2026-07-01 11:15:52 KST info [200] GET /api/v1/records/18327 (Api::V1::RecordsController#show)
2026-07-01 11:15:42 KST info [200] GET /api/v1/records/18327 (Api::V1::RecordsController#show)
2026-07-01 11:15:13 KST info [200] GET /api/v1/records/134695 (Api::V1::RecordsController#show)
2026-07-01 11:15:08 KST info [200] GET /api/v1/records/134715 (Api::V1::RecordsController#show)
즉 대부분의 show 요청은 정상 처리되었고, 특정 record row가 write 트랜잭션과 겹친 1건만 226.6초 지연을 겪었다. sample_trace_id: 3219083934536030476 은 로그 인덱스에서 검색되지 않았다(APM 트레이스에만 존재) — 다음 쿼리는 결과 0:
@trace_id:3219083934536030476
2026-07-01T02:00:00Z → 2026-07-01T02:20:00Z
Status board 컨텍스트: 같은 서비스에서 2026-07-01-svc-cupixworks-api--unknown-1 (10:43–11:02 KST) 인시던트가 방금 resolved 처리되었고, 본 slow trace는 그 tail에 발생했다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 동시간대에 발생한 InnoDB lock wait timeout 폭주가 records 관련 테이블 row lock을 오래 잡아, set_record → BaseRepository.show의 permission_joins read 쿼리가 최대 226.6초 대기 |
45+건의 ActiveRecord::LockWaitTimeout 502 (PanosController, CapturesController, EditingsController, JobsController) 가 10:51–11:19 KST에 집중; slow trace 종료 시각 11:15:18 KST는 lock 폭주 창구간 내부; 대부분의 show는 200 정상 응답 (특정 row에 걸린 소수 요청만 영향) |
정확한 대상 record ID를 확인할 수 없음 (sample_trace_id: 3219083934536030476 은 로그 인덱스에서 미검색, APM 트레이스 전용) — 원인 클래스는 확실하나 어떤 write가 정확히 락을 잡았는지는 미확정 |
Confirmed |
| H2 | Elasticsearch circuit breaker/latency로 인해 요청이 지연 | 없음 — RecordsController#show는 ES에 접근하지 않고 (index 만 ES 사용) MySQL로만 조회 (base_repository.rb:342 query.merge(scope).first); 동시간대에 Elasticsearch circuit breaker 로그 없음 |
Datadog에서 SYS20000 / circuit breaker 관련 에러 없음 |
Rejected |
| H3 | 특정 record의 데이터가 폭발적으로 커져(권한 row 수 등) permission JOIN이 자체적으로 226초 소요 | 없음 — 같은 시간대에 다른 show 요청들은 정상적으로 200 응답, 특정 record ID(예: 134715, 18327) 반복 요청도 정상 | 같은 시간대의 정상 응답 다수, 226초는 InnoDB innodb_lock_wait_timeout 값과 정합적인 패턴 (50s 단위 재시도) |
Rejected |
| H4 | 외부 dependency 장애로 인한 지연 (S3/외부 API 호출 등) | set_record 자체는 외부 호출을 하지 않음 (controller/repository/model만); OPC integration 관련 409 에러가 있으나 별도 tenant/integration 경로로 records show와 무관 |
OPC 오류는 IntegrationRepository#opc_access_token 경로 전용 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 실제 lock을 잡고 있는 write 경로 파악이 우선. 다음 파일에서 트랜잭션 범위를 검토하고 트랜잭션 내부에서 수행되는 원격 호출/무거운 검증을 트랜잭션 밖으로 분리:
app/controllers/api/v1/panos_controller.rb의check_uploading,check_tile_uploading,stitched,check_mask_uploading액션app/controllers/api/v1/captures_controller.rb의update_meta_by_keyapp/controllers/api/v1/editings_controller.rb의update이들이 동일 tenant/facility의 record row를SELECT ... FOR UPDATE/ autoincrement lock / gap lock 형태로 오래 잡고 있을 개연성이 높다.
- read-only 조회 경로에서는 락 대기 시간을 짧게 두어 실패를 빠르게 감지하도록
SELECT에SET LOCAL innodb_lock_wait_timeout = ?을 적용하는 방향 검토. 현재BaseRepository#show는 무제한적으로 대기한다.
단기 개선 (1주 이내)#
BaseRepository#show의permission_joins(default_joins(...))쿼리를 read replica로 라우팅. Rails 6+connected_to(role: :reading)활용. read 쿼리가 write 트랜잭션과 같은 primary 커넥션에서 락 대기하는 구조를 근본적으로 회피할 수 있다.- Slow trace를 자동 알람: 본 클러스터가 감지된 threshold(>500ms)는 너무 관대. Records show의 p99 baseline이 수백 ms 이하이므로 alerts는 5s/10s 수준으로 잡는다.
RecordsController#show에 request timeout(예: rack-timeout 30s)을 걸어 클라이언트 dangling을 방지.
장기 개선 (재발 방지)#
- Pano/Capture 상태 갱신을 큰 트랜잭션에서 분리.
check_uploading,stitched,check_tile_uploading는 상태 전이 검증 +records/capturescounter cache 업데이트를 하나의 트랜잭션에서 수행하는 것으로 추정되며, 이를 (1) 상태 전이 커밋과 (2) counter cache 갱신을 순서를 나누어 짧은 트랜잭션 여러 개로 분리. - InnoDB deadlock/lock-wait 관측성:
SHOW ENGINE INNODB STATUS스냅샷을 lock 폭주 시점에 자동으로 수집하는 sidecar(예: mysqld-exporter + custom job) 도입. - Read 경로 전면 read replica 라우팅 (
ApplicationRecord.connected_to(role: :reading) do ... end) — 특히 dashboard/list/show 계열.
Monitoring#
APM slow-trace 카운트 (본 클러스터 임계):
sum:trace.rack.request.hits{service:cupixworks-api,resource_name:api::v1::recordscontroller#show,env:production} by {resource_name}.as_count()
MySQL InnoDB row lock 대기시간(초):
avg:mysql.innodb.row_lock_time{*}.as_rate()
MySQL 슬로우 쿼리 비율:
avg:mysql.performance.slow_queries{*}.as_rate()
Lock wait timeout 발생 카운트 (Datadog Log-based metric으로 사전 생성 필요; 아래는 raw log query 재현용):
service:cupixworks-api "Lock wait timeout exceeded"
Risk Assessment#
- Risk level: medium — 사용자 1명이 226초 대기 후 응답을 받은 것이고, 동시간대 45건의 502가 upload/edit 흐름을 차단했다. 재현 조건(write 락 폭주)이 다시 오면 read 쪽도 다시 스톨된다.
- 예상 복잡도: standard — write 트랜잭션 범위 축소는 controller/service별 리팩토링이 필요하지만 인터페이스 변경은 없다. Read replica 도입은 인프라 변경 병행 필요(critical 아님).