ES /docs

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#

  1. 04:21:10 KSTPreprocessorService::setVideoImageMatchData 시작 (capture 687514)
  2. ~04:27:44 KSTupdateCaptureMarkMeta 에서 PUT /api/v1/captures/687514/meta/mark 호출
  3. 04:27:44 KST — nginx에서 504 Gateway Time-out 반환, agent에서 HttpError 발생
  4. 04:27:44 KSTBaseService::handlingMessageErrors가 에러를 로깅하고 SQS 메시지 처리 종료
  5. 04:34:15 KST — 동일 capture에 대한 재시도 요청이 API 측에서 ActiveRecord::LockWaitTimeout (502) 발생
  6. 04:39:25 KST — 이후 재시도에서 200 성공
  7. 05:05:09 KST — 추가 재시도에서도 200 성공

Error Log#

Datadog Logs

text
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):

  1. Entry point: PreprocessorService::run — SQS 메시지를 수신하여 capture 전처리 작업을 실행한다.
packages/cupix-capture-preprocessor-agent/src/preprocessor-service.ts:137-140typescript
await this.updateCaptureVideoMeta(cpCapture);
await this.updateCaptureMarkMeta(cpCapture);
await this.updateCaptureCameraInfo(cpCapture);
await this.updateCaptureCalibrationMeta(cpCapture);

여러 meta 업데이트 호출이 순차적으로 같은 capture 레코드에 대해 실행된다.

  1. Failure point: PreprocessorService::updateCaptureMarkMetacupixApi.capture.updateMetaByKey를 호출하여 mark meta를 업데이트한다.
packages/cupix-capture-preprocessor-agent/src/preprocessor-service.ts:737-752typescript
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가 호출된다.

  1. API 호출 래퍼: capture.api.ts:63-66@tesla/typescript-node-sdkcaptureApi.updateMetaByKey를 호출한다.
packages/api/src/api/capture.api.ts:62-67typescript
@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).

  1. Error handling: BaseService::handlingMessageErrors — 504는 getApiErrorToDeleteMessage에서 statusCode >= 400 && statusCode <= 500 범위에 해당하지 않으므로(504 > 500) undefined를 반환하고, checkReceiveCountToDeleteMessage를 통해 재처리 여부를 결정한다.
packages/base/src/base-service.ts:270-275typescript
if (statusCode != undefined && statusCode >= 400 && statusCode <= 500) {
    if (statusCode === 401) return;

    return errorMsg;
}
return;

504 상태 코드는 400-500 범위 밖이므로 SQS 메시지가 삭제되지 않고 남아 재처리 기회가 있다.

API 측 (tesla):

  1. Rails controller: MetableController#update_meta_by_key — JSON을 파싱하여 @model.meta[key]에 할당하고 @model.save를 호출한다.
app/controllers/concerns/metable_controller.rb:42-68ruby
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):

text
Datadog query: service:cupixworks-capture-preprocessor-agent status:error "updateCaptureMarkMeta"
Time: 2026-04-24T18:00:00Z to 2026-04-24T20:30:00Z
json
{
  "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 상태 코드 확인):

text
Datadog query: service:cupixworks-capture-preprocessor-agent 687514
json
{
  "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):

text
Datadog query: service:cupixworks-api "Lock wait timeout" 687514
json
{
  "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 측 성공 로그 (이후 재시도 성공):

text
Datadog query: service:cupixworks-api "captures/687514/meta/mark"
json
{
  "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)"
}
json
{
  "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:44updateCaptureMarkMeta PUT 요청 → nginx 504 (약 6분 처리 후 타임아웃)
  • 04:34:15 — 재시도 요청이 API에서 LockWaitTimeout 502 반환
  • 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주 이내)#

  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 혜택을 받는다. BaseApiModuleprotected 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 — 서비스 레벨에서 적용: PreprocessorServiceBaseService를 통해 this.cupixAuth에 접근할 수 있으므로, 서비스 레벨에서도 감쌀 수 있다. 다만 방법 A가 더 광범위한 보호를 제공한다.

    packages/cupix-capture-preprocessor-agent/src/preprocessor-service.ts:746 (대안)typescript
    await this.cupixAuth.retryable(() =>
        this.cupixApi.capture.updateMetaByKey(cpCapture.id, 'mark', _meta)
    );
    

    관련 파일:

    • CupixAuth.retryable() 정의: packages/api/src/authentication/cupix-auth.ts:59-77
    • Constants.MaxRetries = 5: packages/shared-config/src/constants.ts:8
    • BaseApiModule.auth: packages/api/src/api/base.api.ts:4
  2. 에러 로그 레벨 조정 검토: 504/502 같은 일시적 downstream 에러는 error 대신 warn 레벨로 로깅하고, 최종 재시도 실패 시에만 error로 기록하는 것이 적절하다. 현재는 첫 실패에도 error가 기록되어 노이즈가 발생한다. retryable() 적용 시 중간 재시도는 자동으로 warn 레벨로 로깅되므로, 최종 실패만 error로 전파된다.

장기 개선 (재발 방지)#

  1. 기존 updateMeta API를 활용한 배치 업데이트: Tesla API에 이미 PUT /api/v1/captures/{id}/metaupdate_meta 엔드포인트가 존재한다 (metable_controller.rb:14-29). 이 엔드포인트는 request body의 JSON 전체를 @model.meta에 할당한다. SDK에도 captureApi.updateMeta(id, fields, body) 메서드가 이미 존재한다 (captureApi.d.ts:830).

    API 경로 동작
    updateMetaByKey PUT /captures/{id}/meta/{meta_key} 특정 key 하나만 업데이트
    updateMeta PUT /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)ruby
    def 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.tsupdateMeta wrapper를 추가 (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 경합이 증가하는지 추적:
text
service:cupixworks-capture-preprocessor-agent status:error "504"
  • API 측 lock wait timeout 모니터링:
text
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)회 재시도를 구현하고 있다. BaseApiModuleprotected 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.tsretryable() 메서드 구현 확인 (exponential backoff, 5xx 조건, MaxRetries=5)
  • cupixworks/applications/agents/packages/api/src/api/base.api.tsBaseApiModuleCupixAuthprotected auth로 보유함 확인
  • cupixworks/applications/agents/packages/shared-config/src/constants.tsMaxRetries = 5 상수 확인
  • cupixworks/applications/agents/packages/base/src/base-service.tsthis.cupixAuth getter가 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 업데이트 배치화" 제안을 삭제하고, 기존 updateMeta API 활용 방안으로 통합 교체
  • update_meta 엔드포인트의 동작 설명 (전체 교체, merge 아님) 및 주의사항 추가
  • 구체적 적용 방안 2단계 제시: (a) capture.api.ts에 wrapper 추가, (b) preprocessor에서 meta를 모아서 한 번에 전송
  • 관련 코드 참조 추가

추가 조사 내용:

  • tesla/app/controllers/concerns/metable_controller.rb:14-29update_meta 엔드포인트 실제 구현 확인 (전체 meta 교체 방식)
  • @tesla/typescript-node-sdk/api/captureApi.d.ts:830 — SDK updateMeta(id, fields, body) 메서드 타입 정의 확인
  • cupixworks/applications/agents/packages/api/src/api/capture.api.ts — 현재 updateMeta wrapper 부재 확인 (추가 필요)
  • preprocessor-service.ts:698-699, 746updateMetaByKey 개별 호출 3건 확인 (mark, calibrated_videos, uncalibrated_video_ids)