PreprocessorService::updateCaptureMarkMeta | error - {
RCA: PreprocessorService::updateCaptureMarkMeta | HttpError
Overview#
What Happened#
2026-04-24 19:27:44 UTC에 cupixworks-capture-preprocessor-agent 서비스에서 capture 687514의 mark meta를 업데이트하는 API 호출(PUT /api/v1/captures/687514/meta/mark)이 504 Gateway Time-out으로 실패했다. nginx 프록시 레이어에서 upstream(Rails API) 응답을 기다리다 타임아웃이 발생했으며, API 측에서는 동일 capture에 대해 ActiveRecord::LockWaitTimeout (MySQL lock wait timeout)이 확인되었다. 1건 발생.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | HttpError |
| exception.message | HTTP request failed |
| top_frame | captureApi.js:4528 (@tesla/typescript-node-sdk) |
| env | production, us-west-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| whitingturner | 1 | capture 687514 preprocessor 작업 실패, SQS 메시지 삭제되지 않아 재처리 가능 |
Timeline#
- 04:21:10 KST —
PreprocessorService::setVideoImageMatchData시작 (capture 687514) - ~04:27:44 KST —
updateCaptureMarkMeta에서PUT /api/v1/captures/687514/meta/mark호출 - 04:27:44 KST — nginx에서 504 Gateway Time-out 반환, agent에서
HttpError발생 - 04:27:44 KST —
BaseService::handlingMessageErrors가 에러를 로깅하고 SQS 메시지 처리 종료 - 04:34:15 KST — 동일 capture에 대한 재시도 요청이 API 측에서
ActiveRecord::LockWaitTimeout(502) 발생 - 04:39:25 KST — 이후 재시도에서 200 성공
- 05:05:09 KST — 추가 재시도에서도 200 성공
Error Log#
PreprocessorService::updateCaptureMarkMeta | error - {
name: 'HttpError',
message: 'HTTP request failed',
stack: 'HttpError: HTTP request failed
' +
' at Request._callback (/tmp/agent/dist/node_modules/@tesla/typescript-node-sdk/api/captureApi.js:4528:40)
' +
' at self.callback (/tmp/agent/dist/node_modules/request/request.js:185:22)
' +
' at Request.emit (node:events:524:28)
' +
' at Request.emit (node:domain:489:12)
' +
' at Request.<anonymous> (/tmp/agent/dist/node_modules/request/request.js:1154:10)
' +
' at Request.emit (node:events:524:28)
' +
' at Request.emit (node:domain:489:12)
' +
' at IncomingMessage.<anonymous> (/tmp/agent/dist/node_modules/request/request.js:1076:12)
' +
' at Object.onceWrapper (node:events:638:28)
' +
' at IncomingMessage.emit (node:events:536:35)'
}
Impact#
- Service:
cupixworks-capture-preprocessor-agent - Team: whitingturner
- 발생 횟수: 1
- 최초 발생: 2026-04-24T19:27:44.721Z
- 최근 발생: 2026-04-24T19:27:44.721Z
Root Cause Summary#
Capture 687514의 mark meta 업데이트를 위한 PUT /api/v1/captures/687514/meta/mark API 호출이 downstream인 Rails API(api-tesla.cupix.internal)에서 처리되는 동안 nginx 프록시의 upstream 타임아웃(기본 60초)을 초과하여 504 Gateway Time-out이 발생했다. API 측 로그에서 동일 capture에 대한 ActiveRecord::LockWaitTimeout (MySQL의 innodb_lock_wait_timeout 초과)이 확인되며, 다른 트랜잭션이 해당 capture 레코드에 대한 row lock을 보유하고 있어 meta 업데이트 트랜잭션이 lock 대기 상태에서 타임아웃된 것이 근본 원인이다. preprocessor의 여러 단계(updateCaptureVideoMeta, updateCaptureMarkMeta, updateCaptureCameraInfo 등)가 순차적으로 동일 capture 레코드를 업데이트하는 과정에서, 또는 외부의 동시 요청이 동일 레코드를 업데이트하면서 lock 경합이 발생했다.
Technical Analysis#
Code Path#
Agent 측 (cupixworks):
- Entry point:
PreprocessorService::run— SQS 메시지를 수신하여 capture 전처리 작업을 실행한다.
await this.updateCaptureVideoMeta(cpCapture);
await this.updateCaptureMarkMeta(cpCapture);
await this.updateCaptureCameraInfo(cpCapture);
await this.updateCaptureCalibrationMeta(cpCapture);
여러 meta 업데이트 호출이 순차적으로 같은 capture 레코드에 대해 실행된다.
- Failure point:
PreprocessorService::updateCaptureMarkMeta—cupixApi.capture.updateMetaByKey를 호출하여 mark meta를 업데이트한다.
private updateCaptureMarkMeta = async (cpCapture: CPCapture): Promise<void> => {
logger.debug('PreprocessorService::updateCaptureMarkMeta | begin - capture_id: %d', cpCapture.id);
try {
const _meta = await cpCapture.exportPanoMarkMeta();
if (_meta == undefined) {
logger.debug('PreprocessorService::updateCaptureMarkMeta | end - not found mark info meta');
return;
}
await this.cupixApi.capture.updateMetaByKey(cpCapture.id, 'mark', _meta);
logger.debug('PreprocessorService::updateCaptureMarkMeta | end');
} catch (error) {
this.jobManager.setErrorCode(ErrorCode.Agent.InvalidProcessingOptions);
logger.error('PreprocessorService::updateCaptureMarkMeta | error - %s', error);
throw error;
}
};
에러가 throw되면 BaseService::checkingQueue의 catch 블록에서 handlingMessageErrors가 호출된다.
- API 호출 래퍼:
capture.api.ts:63-66—@tesla/typescript-node-sdk의captureApi.updateMetaByKey를 호출한다.
@SkipInDebug()
async updateMetaByKey(captureId: number, metaKey: string, body: any): Promise<any> {
const api = await this.api();
const res = await api.updateMetaByKey(captureId, metaKey, Fields.MetaFields, body);
return unwrapResult<any>(res);
}
SDK의 HTTP 콜백에서 504 응답을 받아 HttpError를 throw한다 (captureApi.js:4528).
- Error handling:
BaseService::handlingMessageErrors— 504는getApiErrorToDeleteMessage에서statusCode >= 400 && statusCode <= 500범위에 해당하지 않으므로(504 > 500)undefined를 반환하고,checkReceiveCountToDeleteMessage를 통해 재처리 여부를 결정한다.
if (statusCode != undefined && statusCode >= 400 && statusCode <= 500) {
if (statusCode === 401) return;
return errorMsg;
}
return;
504 상태 코드는 400-500 범위 밖이므로 SQS 메시지가 삭제되지 않고 남아 재처리 기회가 있다.
API 측 (tesla):
- Rails controller:
MetableController#update_meta_by_key— JSON을 파싱하여@model.meta[key]에 할당하고@model.save를 호출한다.
def update_meta_by_key
if !@model.updatable_by?(current_user) && (@review.present? && !@review.updatable_by?(current_user))
raise Cupix::Errors::PermissionDenied.new(code: 'PERM10000', reason: 'Permission denied')
end
begin
parsed_meta = JSON.parse(request.raw_post)
@model.meta[params[:meta_key]] = parsed_meta
@model.skip_entrypoint_flush = true if @model.respond_to?(:skip_entrypoint_flush)
@model.save
@model.save가 MySQL row lock을 획득해야 하는데, 다른 트랜잭션이 이미 해당 row를 잠근 상태여서 lock wait timeout이 발생했다.
Log Evidence#
Agent 측 에러 로그 (504 Gateway Time-out):
Datadog query: service:cupixworks-capture-preprocessor-agent status:error "updateCaptureMarkMeta"
Time: 2026-04-24T18:00:00Z to 2026-04-24T20:30:00Z
{
"timestamp": "2026-04-25 04:27:44 KST",
"status": "error",
"message": "BaseService::handlingMessageErrors | Error and message object",
"error.statusCode": 504,
"error.response.body": "<html><head><title>504 Gateway Time-out</title></head>...</html>",
"error.response.request.uri.pathname": "/api/v1/captures/687514/meta/mark",
"error.response.request.method": "PUT",
"error.response.headers.server": "nginx",
"sqsMessage.MessageId": "eabf85d3-cc87-48fb-a79f-2e737e4c72f7",
"sqsMessage.Attributes.ApproximateReceiveCount": "1"
}
Agent warn 로그 (504 상태 코드 확인):
Datadog query: service:cupixworks-capture-preprocessor-agent 687514
{
"timestamp": "2026-04-25 04:27:44 KST",
"status": "warn",
"message": "BaseService::getApiErrorToDeleteMessage | error msg - {\"statusCode\":504,\"requestUriHref\":\"http://api-tesla.cupix.internal/api/v1/captures/687514/meta/mark?fields%5B0%5D=prop&fields%5B1%5D=skat\",\"modelId\":1035655}"
}
API 측 로그 (Lock Wait Timeout — 04:34:15 KST):
Datadog query: service:cupixworks-api "Lock wait timeout" 687514
{
"timestamp": "2026-04-25 04:34:15 KST",
"status": "info",
"message": "[502] PUT /api/v1/captures/687514/meta/mark (Api::V1::CapturesController#update_meta_by_key)",
"error": {
"message": "Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction",
"class": "ActiveRecord::LockWaitTimeout"
}
}
API 측 성공 로그 (이후 재시도 성공):
Datadog query: service:cupixworks-api "captures/687514/meta/mark"
{
"timestamp": "2026-04-25 04:39:25 KST",
"status": "info",
"message": "[200] PUT /api/v1/captures/687514/meta/mark (Api::V1::CapturesController#update_meta_by_key)"
}
{
"timestamp": "2026-04-25 05:05:09 KST",
"status": "info",
"message": "[200] PUT /api/v1/captures/687514/meta/mark (Api::V1::CapturesController#update_meta_by_key)"
}
타임라인 정리:
- 04:21:10 — preprocessor agent가 capture 687514 처리 시작 (setVideoImageMatchData)
- 04:27:44 —
updateCaptureMarkMetaPUT 요청 → nginx 504 (약 6분 처리 후 타임아웃) - 04:34:15 — 재시도 요청이 API에서
LockWaitTimeout502 반환 - 04:39:25 — 이후 재시도에서 200 성공
- 05:05:09 — 추가 요청도 200 성공
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | MySQL row lock 경합으로 인해 API 측 @model.save가 lock wait timeout을 초과하여 504/502 발생 |
API 로그에서 동일 capture 687514에 대한 ActiveRecord::LockWaitTimeout 확인 (04:34:15 KST). nginx 504는 upstream 응답 대기 타임아웃의 전형적 증상. 이후 재시도에서 200 성공 — lock 경합 해소 후 정상 처리 |
— | Confirmed |
| H2 | Rails API 서버 전체 장애 (OOM, crash 등)로 인한 504 | nginx 504 응답의 일반적 원인 중 하나 | 동일 시간대 다른 서비스의 API 호출은 정상이며, 04:33:02에 같은 capture 대상 요청이 200 성공. 동일 에러가 1건뿐으로 서버 전체 장애 패턴 아님 | Rejected |
| H3 | Agent 측 네트워크 문제 (DNS 실패, 연결 거부 등) | — | 에러 응답에 nginx HTML body가 포함되어 있어 연결 자체는 성공. error.errno/error.code/error.syscall이 아닌 HTTP 504 응답 |
Rejected |
| H4 | Preprocessor의 순차 meta 업데이트들이 서로 lock 경합을 일으킴 | updateCaptureVideoMeta, updateCaptureMarkMeta 등이 같은 capture 레코드를 순차적으로 업데이트 (preprocessor-service.ts:137-140). 각 호출이 독립 HTTP 트랜잭션이지만 API 측에서 동시 요청이 겹칠 수 있음 |
순차 호출이므로 agent 자체의 자기 경합은 아님. 다른 agent나 사용자 요청이 동시에 같은 record를 업데이트한 가능성이 더 높음 | Inconclusive |
Fix Recommendation#
즉시 조치 (Critical)#
별도 즉시 조치 불필요. 이 에러는 1건 발생하고 이후 재시도에서 성공했다. 504/502는 일시적 lock 경합의 결과이며, SQS 메시지가 삭제되지 않아 재처리를 통해 자동 복구되었다.
단기 개선 (1주 이내)#
-
CupixAuth.retryable()wrapper를 사용한 API 호출 retry 적용:@agents/api패키지에 이미CupixAuth.retryable()메서드가 존재한다 (cupix-auth.ts:59-77). 이 메서드는statusCode > 500인 에러에 대해 exponential backoff (2^n초, 최대 5회)로 재시도하며, 경로와 상태 코드를 warn 레벨로 로깅한다.방법 A — API wrapper 레벨에서 적용 (권장):
CaptureApiModule.updateMetaByKey()에서this.auth.retryable()로 SDK 호출을 감싸면 모든 호출자가 자동으로 retry 혜택을 받는다.BaseApiModule이protected readonly auth: CupixAuth를 이미 보유하고 있으므로 추가 의존성 없이 적용 가능하다.packages/api/src/api/capture.api.ts:62-67 (변경 제안)typescript@SkipInDebug() async updateMetaByKey(captureId: number, metaKey: string, body: any): Promise\<any> \{ return this.auth.retryable(async () => \{ const api = await this.api(); const res = await api.updateMetaByKey(captureId, metaKey, Fields.MetaFields, body); return unwrapResult\<any>(res); \}); \}방법 B — 서비스 레벨에서 적용:
PreprocessorService가BaseService를 통해this.cupixAuth에 접근할 수 있으므로, 서비스 레벨에서도 감쌀 수 있다. 다만 방법 A가 더 광범위한 보호를 제공한다.packages/cupix-capture-preprocessor-agent/src/preprocessor-service.ts:746 (대안)typescriptawait this.cupixAuth.retryable(() => this.cupixApi.capture.updateMetaByKey(cpCapture.id, 'mark', _meta) );관련 파일:
CupixAuth.retryable()정의:packages/api/src/authentication/cupix-auth.ts:59-77Constants.MaxRetries = 5:packages/shared-config/src/constants.ts:8BaseApiModule.auth:packages/api/src/api/base.api.ts:4
-
에러 로그 레벨 조정 검토: 504/502 같은 일시적 downstream 에러는
error대신warn레벨로 로깅하고, 최종 재시도 실패 시에만error로 기록하는 것이 적절하다. 현재는 첫 실패에도error가 기록되어 노이즈가 발생한다.retryable()적용 시 중간 재시도는 자동으로 warn 레벨로 로깅되므로, 최종 실패만 error로 전파된다.
장기 개선 (재발 방지)#
-
기존
updateMetaAPI를 활용한 배치 업데이트: Tesla API에 이미PUT /api/v1/captures/{id}/meta—update_meta엔드포인트가 존재한다 (metable_controller.rb:14-29). 이 엔드포인트는 request body의 JSON 전체를@model.meta에 할당한다. SDK에도captureApi.updateMeta(id, fields, body)메서드가 이미 존재한다 (captureApi.d.ts:830).API 경로 동작 updateMetaByKeyPUT /captures/{id}/meta/{meta_key}특정 key 하나만 업데이트 updateMetaPUT /captures/{id}/meta전체 meta를 한 번에 교체 현재 preprocessor에서
updateMetaByKey로 개별 호출하는 meta key들:mark(preprocessor-service.ts:746)calibrated_videos(preprocessor-service.ts:698)uncalibrated_video_ids(preprocessor-service.ts:699)
이를 하나의
updateMeta호출로 합치면 DB lock 획득이 3회 → 1회로 줄어 lock 경합 가능성이 크게 감소한다.주의:
update_meta는 전체 meta를 통째로 교체한다 (merge가 아님). 기존 meta를 먼저 읽어서 원하는 key들을 합친 뒤 전체를 보내야 한다.app/controllers/concerns/metable_controller.rb:14-22 (update_meta)rubydef update_meta # ...permission check... @model.meta = JSON.parse(request.raw_post) @model.skip_entrypoint_flush = true if @model.respond_to?(:skip_entrypoint_flush) @model.save end적용 방안:
(a) Agent의
capture.api.ts에updateMetawrapper를 추가 (SDK에 이미 있으므로 간단):packages/api/src/api/capture.api.ts (추가)typescript@SkipInDebug() async updateMeta(captureId: number, body: object): Promise\<any> \{ const api = await this.api(); const res = await api.updateMeta(captureId, Fields.MetaFields, body); return unwrapResult\<any>(res); \}(b)
preprocessor-service.ts에서 각각의 meta 데이터를 모아서 하나의 object로 만든 뒤updateMeta로 한 번에 전송:packages/cupix-capture-preprocessor-agent/src/preprocessor-service.ts (변경 제안)typescript// 기존 meta를 읽어온 뒤 merge const currentMeta = await this.cupixApi.capture.getMeta(cpCapture.id); const mergedMeta = \{ ...currentMeta, mark: markMeta, calibrated_videos: calibrationVideoIds, uncalibrated_video_ids: unCalibrationVideoIds, \}; await this.cupixApi.capture.updateMeta(cpCapture.id, mergedMeta);이렇게 하면 DB lock 획득이 1회로 줄어 lock 경합 가능성이 크게 감소한다.
관련 파일:
update_meta정의:app/controllers/concerns/metable_controller.rb:14-29- SDK
updateMeta타입:@tesla/typescript-node-sdk/api/captureApi.d.ts:830 - Agent
capture.api.ts:packages/api/src/api/capture.api.ts:63(현재updateMetaByKey만 존재) - Preprocessor meta 업데이트:
preprocessor-service.ts:698-699, 746
Monitoring#
- 504/502 에러 빈도를 모니터링하여 lock 경합이 증가하는지 추적:
service:cupixworks-capture-preprocessor-agent status:error "504"
- API 측 lock wait timeout 모니터링:
service:cupixworks-api "Lock wait timeout"
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial
- 1회 발생이며 이후 재시도에서 자동 복구됨. 근본적인 lock 경합 빈도가 증가하지 않는 한 즉각적 위험은 낮다.
Revision History#
Revision 1#
Feedback: SDK에 이미 있는 retry 메커니즘을 사용하도록 wrapper를 만들어야 함. 커스텀 retry 로직 대신 SDK 내장 유틸리티 활용 요청.
판정:
| 피드백 항목 | 판정 | 근거 |
|---|---|---|
| SDK에 있는 retry 메커니즘으로 wrapper 적용 | 수용 | CupixAuth.retryable() (cupix-auth.ts:59-77)이 이미 statusCode > 500 에러에 대해 exponential backoff (2^n초) + 최대 Constants.MaxRetries(5)회 재시도를 구현하고 있다. BaseApiModule이 protected readonly auth: CupixAuth를 보유하므로 (base.api.ts:4), CaptureApiModule.updateMetaByKey()에서 this.auth.retryable()로 SDK 호출을 감싸면 된다. 현재 retryable()은 정의만 되어 있고 외부에서 호출하는 곳이 없으나(자기 재귀만 사용), public 메서드이므로 즉시 사용 가능하다. |
변경 사항:
- Fix Recommendation > 단기 개선 1번: 커스텀 exponential backoff 제안을 제거하고,
CupixAuth.retryable()wrapper 사용으로 교체 - 방법 A(API wrapper 레벨)와 방법 B(서비스 레벨) 두 가지 구체적 적용 방안 제시
- 관련 코드 참조 추가:
cupix-auth.ts:59-77,base.api.ts:4,shared-config/constants.ts:8
추가 조사 내용:
cupixworks/applications/agents/packages/api/src/authentication/cupix-auth.ts—retryable()메서드 구현 확인 (exponential backoff, 5xx 조건, MaxRetries=5)cupixworks/applications/agents/packages/api/src/api/base.api.ts—BaseApiModule이CupixAuth를protected auth로 보유함 확인cupixworks/applications/agents/packages/shared-config/src/constants.ts—MaxRetries = 5상수 확인cupixworks/applications/agents/packages/base/src/base-service.ts—this.cupixAuthgetter가PreprocessorService에도 노출됨 확인retryable()사용처 검색: 현재 외부 호출 없음 (자기 재귀만) — 새로운 활용 대상으로 적합
Revision 2#
Feedback: tesla API에 이미 update_meta (PUT /api/v1/captures/{id}/meta)가 있으니 새로운 API를 만들지 않고 이를 사용하는 방향으로 Fix Recommendation 수정. 여러 meta key를 한 번에 업데이트하여 DB lock 경합을 줄이는 방안.
판정:
| 피드백 항목 | 판정 | 근거 |
|---|---|---|
기존 updateMeta API 활용하여 배치 업데이트 |
수용 | metable_controller.rb:14-29에서 update_meta 엔드포인트 확인 — @model.meta = JSON.parse(request.raw_post) + @model.save로 전체 meta를 한 번에 교체. SDK에도 captureApi.updateMeta(id, fields, body) 메서드 존재 (captureApi.d.ts:830). 현재 agents capture.api.ts에는 updateMetaByKey만 래핑되어 있고 updateMeta는 없으므로, wrapper 추가만으로 즉시 사용 가능. preprocessor의 updateMetaByKey 3회 호출(mark, calibrated_videos, uncalibrated_video_ids)을 1회 updateMeta로 통합하면 lock 획득 3→1회로 감소. |
변경 사항:
- Fix Recommendation > 장기 개선 1번: 기존 "Meta 업데이트 API 최적화 (partial update)" + "Capture meta 업데이트 배치화" 제안을 삭제하고, 기존
updateMetaAPI 활용 방안으로 통합 교체 update_meta엔드포인트의 동작 설명 (전체 교체, merge 아님) 및 주의사항 추가- 구체적 적용 방안 2단계 제시: (a)
capture.api.ts에 wrapper 추가, (b) preprocessor에서 meta를 모아서 한 번에 전송 - 관련 코드 참조 추가
추가 조사 내용:
tesla/app/controllers/concerns/metable_controller.rb:14-29—update_meta엔드포인트 실제 구현 확인 (전체 meta 교체 방식)@tesla/typescript-node-sdk/api/captureApi.d.ts:830— SDKupdateMeta(id, fields, body)메서드 타입 정의 확인cupixworks/applications/agents/packages/api/src/api/capture.api.ts— 현재updateMetawrapper 부재 확인 (추가 필요)preprocessor-service.ts:698-699, 746—updateMetaByKey개별 호출 3건 확인 (mark,calibrated_videos,uncalibrated_video_ids)