Api::V1::ClustersController#resource_upload_url (avg 16942ms, max 16942ms)
RCA: Api::V1::ClustersController#resource_upload_url latency spike (16.9s)
Overview#
What Happened#
2026-07-11 04:20 KST 시점에 cupixworks-api (us-west-2, tenant cupix) 의 POST /api/v1/clusters/:id/resources/:kind/upload_url 요청 한 건이 16,942 ms 동안 지속된 뒤 정상(HTTP 200)으로 완료되었다. 동일한 시간대(UTC 18:15–19:37, KST 03:15–04:37) 에 다른 PUT 계열 엔드포인트(Api::V1::JobsController#update, #complete_action, Api::V1::PanosController#check_uploading, check_tile_uploading, update_meta_by_key, Api::V1::PointcloudsController#check_uploading) 에서 Mysql2::Error::TimeoutError: Lock wait timeout exceeded 예외가 50건 이상 관측되었다. 이 latency span 은 DB row lock 경합으로 인한 대기시간의 일부이며, innodb_lock_wait_timeout 만료 전에 잠금을 획득해 성공한 케이스로 판단된다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::ClustersController#resource_upload_url |
| avg_duration_ms | 16942 |
| max_duration_ms | 16942 |
| sample_trace_id | 8628019757318599716 |
| env | production, us-west-2 |
| tenant | cupix |
| cluster_type | latency |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (upload/state 변경 엔드포인트) | 1 slow span (200) + 50+ 502 (LockWaitTimeout) | 업로드 시작/완료 및 job/pano/pointcloud 상태 변경 요청이 지연되거나 실패. 클라이언트 재시도로 사용자 관점에서는 회복 가능하지만 배치 파이프라인에는 backlog 발생. |
Timeline#
- 2026-07-11 03:15 KST —
Api::V1::PanosController#update_meta_by_key,#check_tile_uploading,Api::V1::JobsController#complete_action등에서Mysql2::Error::TimeoutError: Lock wait timeout exceeded가 반복 발생 (UTC 18:15Z). - 2026-07-11 04:20:39 KST — 문제의 span 발생.
POST /api/v1/clusters/.../resources/.../upload_url이 16,942 ms 소요 후 200 응답 (UTC 19:20:39Z). - 2026-07-11 04:23–04:37 KST —
Api::V1::JobsController#update요청 다수가 LockWaitTimeout 로 502 실패 (UTC 19:23–19:37Z). - 이후 — Datadog 상에서 정상 응답 시간(수백 ms 수준)의
resource_upload_url요청이 다시 정상적으로 처리됨.
Error Log#
{
"resource_name": "Api::V1::ClustersController#resource_upload_url",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 16942,
"max_ms": 16942,
"sample_trace_id": "8628019757318599716"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1 (>500ms 필터 기준 span)
- 최초 발생: 2026-07-11 04:20 KST
- 최근 발생: 2026-07-11 04:20 KST
Root Cause Summary#
Api::V1::ClustersController#resource_upload_url 는 MultipleResourcableController#resource_upload_url 에서 @resource.upload_url 을 호출하고, 이는 다시 Storagable::Resource#upload_url 에서 state_machine 이벤트 self.uploading 을 발생시켜 resources 테이블에 UPDATE 를 수행한다. 이 UPDATE 는 대상 row 에 대한 InnoDB row-level exclusive lock 을 필요로 한다. 동시간대에 다른 트랜잭션(예: JobsController#complete_action, PanosController#check_uploading 등 업로드 완료/상태 갱신 계열)이 관련 row 를 오래 잠그고 있어 이번 요청은 16.9 초 동안 잠금을 기다린 후 획득에 성공했다. 같은 시간대의 50+ 건 502 LockWaitTimeout 은 잠금을 획득하지 못한 형제 요청들이며, 본 span 은 잠금 대기가 innodb_lock_wait_timeout (일반적으로 50s) 만료 이전에 해소되어 성공한 케이스이다. 즉, 코드 자체의 버그가 아닌 DB row lock 경합이 근본 원인이다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/clusters_controller.rb:1(Api::V1::ClustersController,include MultipleResourcableControllerat line 10) - Handler:
app/controllers/concerns/multiple_resourcable_controller.rb:110(#resource_upload_url) - Model event:
app/models/concerns/storagable/resource.rb:101(Storagable::Resource#upload_url) - State transition:
app/models/concerns/statable/resource.rb:18(event :uploading do transition all => :uploading end) - S3 signing:
app/models/concerns/storagable/resource.rb:111(#presigned_upload_url)
def resource_upload_url
case @resource.state_name
when :created, :uploading, :missing, :done, :error
@resource.upload_url
else
raise Cupix::Errors::InvalidState.new(code: 'STAT10000', reason: "Invalid resource state: #{@resource.state}")
end
rescue Cupix::Errors::Parameter => e
raise e
else
render_api Renderable.new({
contents: @resource,
serializer: ResourceSerializer,
serializer_option: {
fields: {
resource: @fields
},
is_collection: false
}
})
end
def upload_url(revision = nil, **kwags)
self.uploading unless self.uploading? # ← state_machine event → UPDATE resources SET state='uploading'
self.presigned_upload_url(revision, force: kwags[:force])
end
# ...
def presigned_upload_url(revision, force: false)
revision ||= self.revision + 1
if !force && revision < self.revision
raise Cupix::Errors::Parameter.new(code: 'ARG10001', reason: "Invalid revision: #{revision}")
end
client = Cupix::StorageService.client(storage_option: storage_option)
signer = Aws::S3::Presigner.new(client: client)
bucket_name = storage_option.s3_source_bucket_name
expires_in = 2.hour.to_i
signer.presigned_url(
:put_object,
bucket: bucket_name,
key: object(revision).key,
storage_class: 'ONEZONE_IA',
expires_in: expires_in,
acl: 'bucket-owner-full-control'
)
end
state_machine initial: :created do
# State
state :created, :uploading, :missing, :processing, :done, :error do
end
event :created do
end
event :uploading do
transition all => :uploading # ← always writes to DB even if already :uploading (mitigated by `unless self.uploading?` in caller)
end
# ...
end
기대 동작: 정상적인 경우 self.uploading 은 수 ms 내에 resources row 를 UPDATE 하고, Aws::S3::Presigner 로 pre-signed URL 을 즉시 생성해 총 100–500ms 수준으로 응답한다. 실제로 인접 시간대의 다른 resource_upload_url 로그는 지연 없이 성공하고 있다 (아래 Log Evidence 참고).
실제 동작: 같은 시각 다른 트랜잭션이 관련 row (또는 상위 리소스 row) 에 exclusive lock 을 보유하고 있어 self.uploading 의 UPDATE 가 잠금 대기에 걸림. 16.9 초 후 잠금이 해제되어 UPDATE 가 성공하고, S3 서명 후 200 반환. 같은 시간대 다른 요청들은 innodb_lock_wait_timeout 만료로 Mysql2::Error::TimeoutError 를 받아 502 로 실패.
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-api "Api::V1::ClustersController#resource_upload_url"
(2026-07-10T18:00:00Z → 2026-07-10T20:00:00Z)
service:cupixworks-api "Lock wait timeout"
(2026-07-10T18:00:00Z → 2026-07-10T20:00:00Z)
동일 시간대에 Mysql2::Error::TimeoutError: Lock wait timeout exceeded 가 여러 엔드포인트에서 반복 관측됨:
{
"timestamp": "2026-07-11 04:23:21 KST",
"status": "info",
"message": "[502] PUT /api/v1/jobs/1188565 (Api::V1::JobsController#update)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
{
"timestamp": "2026-07-11 03:23:57 KST",
"status": "info",
"message": "[502] PUT /api/v1/pointclouds/1214749/check_uploading (Api::V1::PointcloudsController#check_uploading)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
{
"timestamp": "2026-07-11 03:23:13 KST",
"status": "info",
"message": "[502] PUT /api/v1/jobs/1188369/actions/postprocessor/complete (Api::V1::JobsController#complete_action)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
문제의 span 시각(04:20:39 KST) 전후 Api::V1::ClustersController#resource_upload_url 로그는 200 응답이며, 시간대 앞뒤로 다수의 요청이 큰 지연 없이 처리되고 있음이 확인됨 — 즉, 본 latency 는 지속적 성능 저하가 아닌 일시적 lock 경합의 산물이다:
2026-07-11 04:20:57 KST [200] POST /api/v1/clusters/1444621/resources/preview_image/upload_url
2026-07-11 04:23:13 KST [200] POST /api/v1/clusters/1444731/resources/preview_image/upload_url
2026-07-11 04:26:37 KST [200] POST /api/v1/clusters/1444528/resources/preview_image/upload_url
Status board (svc:cupixworks-api::unknown) 조회 결과 동일 서비스에서 2026-07-06 이후로 “cupixworks-api service degraded” 인시던트가 7건 반복 발생했으며 (직전 인시던트 2026-07-10-svc-cupixworks-api--unknown-4 는 UTC 18:18–18:43Z 에 6개 클러스터를 묶었다), 이는 본 이슈가 간헐적으로 재발하는 DB 부하/lock 경합 패턴임을 시사한다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Storagable::Resource#upload_url 의 state_machine UPDATE 가 다른 트랜잭션이 잡고 있는 row lock 을 기다렸다 (DB row lock 경합) | 동일 시간대(2026-07-10 18:15–19:37Z) 에 여러 PUT 계열 엔드포인트에서 Mysql2::Error::TimeoutError: Lock wait timeout exceeded 50+ 건. resource_upload_url 코드가 self.uploading 을 통해 UPDATE 를 수행함 (storagable/resource.rb:102). 16.9s 는 innodb_lock_wait_timeout 만료 이전 값. |
— | Confirmed |
| H2 | Aws::S3::Presigner (signer.presigned_url) 자체의 네트워크/서명 지연 |
presigner 는 로컬 서명(HMAC-SHA256) 이며 실제 S3 API 호출은 없음 (client 만 만들고 URL 생성). 동일 시각 다른 upload_url span 은 정상 응답. |
S3 outage/latency 를 시사하는 Aws:: 로그나 warn 이 window 내에 없음. presigner 는 network I/O 미수반. |
Rejected |
| H3 | 외부 dependency (S3 리전 outage) 로 인한 지연 | 없음 (status board dep:* scope active 없음, presigner 는 오프라인 서명) |
동일 시각 다른 리소스 endpoint (sketches, captures alignments, panos 등) 는 정상 처리됨. | Rejected |
| H4 | ClustersController 내부의 before_action :set_multiple_resource (multiple_resourcable_controller.rb:7) 에서 발생한 SELECT 자체가 느렸다 |
— | 해당 SELECT 는 인덱스된 lookup (resources.kind / key). 다른 slow log 나 slow query 로그가 인접 시각에 없음. |
Inconclusive (증거 부족) |
Fix Recommendation#
즉시 조치 (Critical)#
- 별도의 코드 변경 없이도 자동 복구된 사례이지만, 재발 감시가 즉시 필요하다. status board 상
svc:cupixworks-api::unknown인시던트가 최근 5일간 7회 반복되었으므로, 아래 Monitoring 섹션 쿼리를 대시보드에 추가. Storagable::Resource#upload_url(app/models/concerns/storagable/resource.rb:101-104) 의 무조건적self.uploading호출은 이미unless self.uploading?가드가 있음. 다만 state_machine 이벤트가 항상transition all => :uploading이라 이미uploading상태에서도 트리거되면 UPDATE 를 유발 (statable/resource.rb:19). 이 재입력 UPDATE 를 정말로 피하고 싶다면 이벤트 정의를transition all - [:uploading] => :uploading으로 좁힐 수 있으나,unless self.uploading?가드로 이미 방지되므로 우선순위는 낮음.
단기 개선 (1주 이내)#
- Long-running transaction 을 찾아 원인 트랜잭션을 특정한다. 후보:
Api::V1::JobsController#complete_action(postprocessor/complete) — 502 로그에서 반복 실패. 이 액션이 다수의resources/panos/pointcloudsrow 를 하나의 트랜잭션 안에서 UPDATE 하고 있을 가능성.Api::V1::PanosController#check_uploading,#check_tile_uploading,#update_meta_by_key— 모두 502 발생. 배치 형태로 대량 호출될 때 트랜잭션이 길어질 수 있음.
- MySQL
SHOW ENGINE INNODB STATUS/information_schema.INNODB_TRX스냅샷을 인시던트 발생 시점 자동 수집하는 hook 을 추가해 lock holder 파악을 자동화. resource_upload_url은 idempotent 한 URL 발급 API 이므로,self.uploading이 이미:uploading상태이면 UPDATE 스킵하도록state_machine이벤트 정의를transition all - [:uploading] => :uploading으로 조정해 불필요한 row lock 획득 자체를 줄이는 방안 검토 (app/models/concerns/statable/resource.rb:18-20). 이미Storagable::Resource#upload_url이unless self.uploading?로 감싸고 있어 실질 영향은 제한적이나, 다른 호출 경로(예:upload_credentialsinmultiple_resourcable_controller.rb:132-141) 에는 이 guard 가 없음.
장기 개선 (재발 방지)#
- 트랜잭션 경계 축소:
#complete_action,#check_uploading계열에서 트랜잭션 내부에서 수행하는 S3/외부 I/O 를 트랜잭션 밖으로 빼고, 다수 row 갱신은 배치 UPDATE 로 재작성해 트랜잭션 유지 시간을 줄인다. - 읽기 전용 fast path:
resource_upload_url이 반드시 상태 전이를 유발해야 하는지 재검토. presigned URL 발급 자체는 상태 없이도 가능하며,state는 클라이언트가 실제 업로드를 시작한 시점(check_uploading) 에 갱신해도 무방해 보인다. 이 변경을 하면 GET 스타일 URL 발급이 완전히 DB 쓰기 없이 처리 가능. - Status board 반복
svc:cupixworks-api::unknown인시던트 (7회/5일) 를 종합해 별도의 DB lock 경합 후속 RCA 를 진행. 본 span 은 그 증상 중 하나일 뿐.
Monitoring#
Datadog 릴리즈 대시보드용 timeseries widget 쿼리 (모두 writing-datadog-monitoring-queries skill 기준의 timeseries-safe 문법):
resource_upload_urlp95/p99 응답시간 (spike 재발 감지):
p95:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::clusterscontroller#resource_upload_url}
p99:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::clusterscontroller#resource_upload_url}
- Lock wait timeout 발생률 (근본 원인 추적용):
sum:trace.rack.request.errors{service:cupixworks-api,error_type:activerecord::lockwaittimeout}.as_count()
- Storagable resource UPDATE 지연 대체 지표 (Rails 전체 request 오류 비율):
sum:rails.request.errors{service:cupixworks-api}.as_rate()
- 참고: DB 측 지표가 있다면 (RDS/mysql integration) 아래도 함께 대시보드에 추가:
avg:mysql.innodb.row_lock_time{service:cupixworks-api}
avg:mysql.innodb.row_lock_waits{service:cupixworks-api}.as_rate()
Risk Assessment#
- Risk level: medium (단일 span 은 200 이지만, 같은 근본 원인으로 인한 502 가 다수 발생. 서비스 저하가 5일 내 7회 재발 중).
- 예상 복잡도: standard (즉시 조치는 모니터링 추가 및 후속 RCA. 트랜잭션 경계 축소 등 근본 수정은 별도 티켓 필요).