TransferManager::failTask | path: /tmp/workspace/680039/videos/640181/original/640181_01006.jpg, url
RCA: TransferManager::failTask | url: undefined
Error Log#
TransferManager::failTask | path: /tmp/workspace/680039/videos/640181/original/640181_01006.jpg, url: undefined, count: 0/5
Impact#
- Service:
cupixworks-capture-postprocessor-agent - Team: tridentconstruction
- 발생 횟수: 214
- 최초 발생: 2026-04-13T21:29:54.970Z
- 최근 발생: 2026-04-13T21:30:16.592Z
Root Cause Summary#
Job 1012296 (capture 680039, key: xVn7NC78b)이 동일 SQS 메시지로 두 번 실행되었습니다. 첫 번째 실행(20:31~20:34 UTC)에서 500개 pano 중 285개가 성공적으로 API에 생성되었으나, 작업이 완료/정리된 후 약 53분 뒤에 같은 메시지가 다시 처리되었습니다(21:27 UTC). 두 번째 실행에서 UploadNewPanosTask.init()이 아직 서버에 생성되지 않은 나머지 215개 pano에 대해 cupixApi.pano.create() API를 호출했으나, API가 4xx 에러(capture 상태 불일치 또는 중복 리소스)를 반환했습니다. checkStatusCode가 4xx 에러에 대해 재시도를 건너뛰도록 설계되어 있어, 각 task가 retry 없이 즉시 실패했습니다(count: 0/5). 실패한 task의 uploadUrl은 한 번도 설정되지 않아 url: undefined로 기록되었습니다.
Technical Analysis#
Code Path#
- Entry point:
TransferManager.uploadNewPanos()—transfer.manager.ts:230 UploadNewPanosContainer.createTasks()—upload-new-panos.container.ts:23에서 각 CPPano에 대해UploadNewPanosTask생성TransferManager.transferTask()—transfer.manager.ts:193에서 task 실행 시작UploadNewPanosTask.init()—upload-new-panos.task.ts:23에서 pano API 생성 시도- Failure point:
transfer.manager.ts:196—task.url == undefined검사에서 실패
업로드 대상 필터링 (finalization-service.ts:268):
// finalization-service.ts:268
const cpPanos = cpVideo.cpPanos.filter(it => it && it.isSkatData && it.id === Constants.UnknownId);
id === UnknownId (-1)인 pano만 업로드 대상으로 선택됩니다. 첫 번째 실행에서 성공적으로 생성된 285개 pano는 metadata에 서버 ID가 기록되어 두 번째 실행 시 필터링되지만, 나머지 215개는 여전히 id === -1인 상태로 업로드 대상이 됩니다.
Task 초기화 (upload-new-panos.task.ts:23-44):
// upload-new-panos.task.ts:23-44
init = async (): Promise<void> => {
logger.debug('UploadNewPanosTask::init | begin - cpPano target: %s', this.target);
if (this.panoId !== Constants.UnknownId) {
logger.debug('UploadNewPanosTask::init | end - exist pano id: %d', this.panoId);
return; // 이미 서버 ID가 있으면 조기 반환 — uploadUrl 미설정
}
const cpCapture = this._container.cpCapture;
const createPanoRequest = {
capture_id: cpCapture.id,
video_id: this.videoId,
name: this.cpPano.name,
exif: this.cpPano.cpVideo?.metaData,
pano_type: this.cpPano.panoType
};
const responsePano = await this._container.cupixApi.pano.create(createPanoRequest);
this.cpPano.setServerPanoData(responsePano); // upload_url 설정
// ...
};
panoId === UnknownId인 경우 cupixApi.pano.create()를 호출합니다. 이 API 호출이 4xx 에러를 반환하면 예외가 발생하고, uploadUrl은 설정되지 않습니다.
URL 검증 및 실패 처리 (transfer.manager.ts:193-214):
// transfer.manager.ts:193-214
private transferTask = async (task: BaseTask): Promise<void> => {
try {
await task.init();
if (task.url == undefined || task.target == undefined) {
throw new Error('TransferManager::transferTask | url or target is undefined');
}
// ... upload logic ...
} catch (error) {
this.retryTask(task, error);
}
};
재시도 판단 (transfer.manager.ts:168-191):
// transfer.manager.ts:168-191
private checkStatusCode = (error: any): boolean => {
if (error?.statusCode != undefined && error.statusCode > 400 && error.statusCode < 500) return false;
return true;
};
retryTask = (task: BaseTask, error: any): void => {
if (this.checkStatusCode(error) && task.isRetry()) {
// ... retry after RetryInterval ...
} else {
this.failTask(task, error); // 4xx 에러 → 즉시 실패
}
};
pano.create() API가 4xx 응답(예: 409 Conflict, 422 Unprocessable)을 반환하면 error.statusCode가 400~500 사이가 되어 checkStatusCode가 false를 반환하고, task는 재시도 없이 즉시 failTask로 처리됩니다. 이때 retryCount = 0이므로 count: 0/5로 기록됩니다.
failTask에서 정지 (transfer.manager.ts:158-166):
// transfer.manager.ts:158-166
private failTask = (task: BaseTask, error: any): void => {
this._running = false;
this._stop = true; // 전체 큐 정지
const idx = this._transferring.indexOf(task);
this._transferring.splice(idx, 1);
this._failed.push(task);
task.finalize(TaskType.Failed, error);
logger.error('TransferManager::failTask | path: %s, url: %s, count: %d/%d',
task.target, task.url, task.retryCount, Constants.MaxRetries);
};
_stop = true로 큐를 정지시키지만, BaseContainer.checkTaskFail (line 52)에서 failedTask.length > 0이면 _callbackFailed를 호출하여 전체 업로드 프로세스를 실패로 종료시킵니다.
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-capture-postprocessor-agent status:error "TransferManager::failTask" @environment:production
첫 번째 실행 (20:31~20:34 UTC) — 정상 완료:
[20:31:41.611Z] INFO BaseService::runByMessage | id: 1012296
[20:31:41.648Z] INFO JobManager::loadJob | begin - job id: 1012296
[20:31:41.692Z] INFO JobManager::loadJob | end - job id: 1012296
[20:31:42.141Z] INFO CPCapture::fromMeta | key: xVn7NC78b, version: 1
[20:34:32.560Z] INFO BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/680042
[20:34:32.835Z] INFO AwsQueueManager::deleteMessage | end - message id: d5504247-ea62-4c69-9fc2-4b506a2f7aa6
첫 번째 실행은 약 3분 동안 처리되어 정상 완료 후 workspace를 정리하고 SQS 메시지를 삭제했습니다.
두 번째 실행 (21:27~21:30 UTC) — 에러 발생:
[21:27:00.204Z] INFO BaseService::runByMessage | id: 1012296
[21:27:00.468Z] INFO JobManager::loadJob | end - job id: 1012296
[21:27:01.242Z] INFO CPCapture::fromMeta | key: xVn7NC78b, version: 1
[21:27:07.982Z] INFO FileSystemManager::loadSkatResults | read file meta - created at: Mon Apr 13 20:26:51 2026 GMT
약 53분 후 동일 job이 다시 실행되었습니다. 메타데이터 로딩 이후 ~2.5분 동안 처리가 진행된 후:
[21:29:54.969Z] ERROR BaseTaskContainer::checkTaskFail | Failed - Total: 500, Done: 285, Failed: 2
[21:29:54.970Z] ERROR TransferManager::failTask | path: /tmp/workspace/680039/videos/640181/original/640181_01006.jpg, url: undefined, count: 0/5
[21:29:55.023Z] ERROR BaseTaskContainer::checkTaskFail | Failed - Total: 500, Done: 285, Failed: 3
[21:29:55.023Z] ERROR TransferManager::failTask | path: /tmp/workspace/680039/videos/640181/original/640181_01009.jpg, url: undefined, count: 0/5
...
[21:30:16.592Z] ERROR TransferManager::failTask | path: /tmp/workspace/680039/videos/640181/original/640181_01707.jpg, url: undefined, count: 0/5
BaseTaskContainer::checkTaskFail의 카운터를 보면:
- Total: 500 (전체 업로드 task 수)
- Done: 285 (성공 — 첫 번째 실행에서 이미 완료된 것으로 추정)
- Failed: 2에서 215까지 증가 (214개 에러 로그와 일치, 초기 1은 별도 실패)
모든 실패한 task에서 url: undefined, count: 0/5가 일관되게 나타나며, 이는 API 호출 실패 시 4xx 응답으로 인한 즉시 실패 패턴을 확인합니다.
API 측 로그 (capture 680042):
service:cupixworks-api "680042" @environment:production
[21:29:48.421Z] POST 400 /api/v1/captures/680042/resources — "Duplicate kind: alignments_sampled" (Cupix::Errors::Parameter, ARG10001)
[21:29:51.412Z] POST 400 /api/v1/captures/680042/resources — "Duplicate kind: alignments_all" (Cupix::Errors::Parameter, ARG10001)
API에서 리소스 중복 생성 시도에 대해 400 에러를 반환한 것을 확인. 이는 두 번째 실행에서 이미 존재하는 리소스를 다시 생성하려고 시도했음을 보여줍니다. Pano 생성 API에서도 동일한 패턴의 4xx 에러가 발생했을 가능성이 높으나, 해당 로그는 Datadog에서 video 640181 관련 pano 생성 요청이 검색되지 않아 직접 확인하지 못했습니다 — uncertain, needs verification.
Fix Recommendation#
즉시 조치 (Critical)#
SQS 메시지 중복 처리 방지: UploadNewPanosTask.init() (upload-new-panos.task.ts:23-44)에서 pano.create() API 호출이 4xx 에러를 반환할 때의 처리를 개선해야 합니다. 구체적으로:
upload-new-panos.task.ts:39—cupixApi.pano.create()호출 실패 시 에러를 구분하여 처리 (409 Conflict인 경우 기존 pano의 upload URL을 재발급받는 fallback 로직 추가)transfer.manager.ts:168-171—checkStatusCode에서 4xx 에러를 모두 재시도 불가로 처리하는 현재 로직은 유지하되, 실패 원인을 더 명확히 로깅할 필요가 있음
단기 개선 (1주 이내)#
멱등성(Idempotency) 확보: 동일 job이 재실행될 때 이미 생성된 pano를 감지하고 upload URL만 재발급받을 수 있도록 UploadNewPanosTask.init() 로직을 개선해야 합니다. 현재 panoId !== UnknownId일 때 조기 반환하면서 uploadUrl을 설정하지 않는 문제도 함께 수정해야 합니다:
upload-new-panos.task.ts:25-28— 조기 반환 시renew()를 호출하여 기존 pano의 upload URL을 재발급finalization-service.ts:268— 필터 조건에서 이미uploadUrl이 있는 pano는 제외하되, ID가 있지만uploadUrl이 없는 pano도 포함하도록 개선
장기 개선 (재발 방지)#
- SQS 메시지 처리에 idempotency key 도입하여 동일 job의 중복 실행 자체를 방지
BaseService.runByMessage()에서 job 상태를 확인하여 이미 완료된 job의 재실행을 건너뛰는 guard 추가- Pano 생성 API에서 중복 생성 시 409 대신 기존 pano 데이터(upload_url 포함)를 반환하는 upsert 방식 검토
Monitoring#
count:0/5가 포함된failTask에러 — 재시도 없이 즉시 실패하는 task 급증 감지:
service:cupixworks-capture-postprocessor-agent status:error "failTask" "count: 0/5"
- 동일 job ID의 중복 실행 감지:
service:cupixworks-capture-postprocessor-agent "BaseService::runByMessage" | stats count by @job_id | filter count > 1
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard