ES /docs

AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/125328/potree/octree.bin

RCA: AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/125328/potree/octree.bin

Overview#

What Happened#

2026-06-24 15:06 KST에 cupixworks-any-potree-agent (eu-central-1) 가 pointcloud id 125328 의 Potree 변환 결과를 S3 에 업로드하는 도중 octree.bin 파일 1개의 업로드가 실패했다. 에러는 AwsS3Manager::uploadDirectoryToS3 의 per-file catch 블록에서 swallow 되었기 때문에, agent 는 그대로 후속 단계(checkUploading, cleanUpAnythingRelatedModel)를 계속 진행했고 실패한 파일의 원인 정보는 로그에 남지 않았다.

Quick Facts#

Field Value
exception.class (없음 — caught error 가 로그 메시지에 포함되지 않음)
exception.message AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/125328/potree/octree.bin
top_frame applications/agents/packages/base/src/manager/aws-s3.manager.ts:156
env production, eu-central-1
tenant cupix

Affected Teams#

Team / Domain Error Count Impact
crcc-sama / Potree pipeline 1 Pointcloud 125328 의 Potree octree 데이터가 S3 에 부분적으로 누락된 채 업로드 완료로 처리되었을 가능성. 뷰어에서 해당 노드만 로드 실패 가능.

Timeline#

  1. 2026-06-24 15:04 KSTPotreeService::runByMessage | id: 125328 — pointcloud 125328 처리 시작
  2. 2026-06-24 15:06:11 KSTAwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/125328/potree/octree.binoctree.bin 업로드 중 예외 발생, per-file catch 가 삼킴
  3. 2026-06-24 15:06:12 KSTPotreeService::cleanUpAnythingRelatedModel | path: /tmp/workspace/125328 — 실패에도 불구하고 정상 종료 경로 진입 (workspace 삭제)

Error Log#

Datadog Logs

text
AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/125328/potree/octree.bin

Impact#

  • Service: cupixworks-any-potree-agent
  • Team: crcc-sama
  • 발생 횟수: 1
  • 최초 발생: 2026-06-24 15:06 KST
  • 최근 발생: 2026-06-24 15:06 KST

부수 영향:

  • pointcloud 125328 의 octree.bin 이 S3 에 누락되었을 수 있다. octree.bin 은 Potree 의 모든 옥트리 노드 페이로드를 담는 핵심 바이너리이므로 누락 시 뷰어 로드가 사실상 실패한다.
  • 그럼에도 agent 는 후속 checkUploading 호출과 cleanup 까지 정상 흐름으로 진행했고, PotreeService::handlingMessageErrors 가 호출되지 않았다 (같은 시각대 로그 검색에서 해당 메시지 없음). 즉 pointcloud 상태가 잘못 Uploaded 로 마킹되었을 수 있다.

Root Cause Summary#

AwsS3Manager.uploadDirectoryToS3 의 per-file 업로드 루프(uploadChunk)는 개별 파일 업로드 실패를 try/catch 로 잡아 logger.error 만 호출하고 그대로 진행한다(throw 하지 않음). 따라서 일부 파일이 실패해도 디렉터리 업로드 전체는 성공으로 종료되고, 호출자인 PotreeService.uploadPotreeFilesrunByMessage 도 정상 경로(checkUploading, updatePotreeState(Uploaded) 등)를 계속 진행한다. 추가로, catch 블록의 로그 호출은 logger.error('...failed: %s', filePath, error) 형태이며, 포맷 문자열에 %s 가 하나뿐이라서 실제 error 인자는 winston splat 잔여로 남았다가 JSON 트랜스포트의 filterNumericKeys 단계에서 빠지거나 Symbol splat 키에 들어가 직렬화에서 제거된다(packages/utils/src/cplogger.ts:170-183). 결과적으로 Datadog 에는 파일 경로만 남고 에러의 원인(예: 403 access denied, ECONNRESET, NoSuchKey 등)이 사라진다.

Technical Analysis#

Code Path#

  • Entry point: applications/agents/packages/cupix-tesla-potree-agent/src/potree-service.ts:180 (runByMessage)
  • 업로드 호출: applications/agents/packages/cupix-tesla-potree-agent/src/potree-service.ts:337-379 (uploadPotreeFiles)
  • Failure point: applications/agents/packages/base/src/manager/aws-s3.manager.ts:155-157 (per-file catch block)

uploadPotreeFiles 는 S3 자격증명을 받아 awsS3Manager.uploadDirectoryToS3 를 호출한 직후 곧바로 checkUploading API 를 호출한다. 즉 디렉터리 업로드가 throw 하지 않으면 전부 성공으로 간주된다.

applications/agents/packages/cupix-tesla-potree-agent/src/potree-service.ts:370-378typescript
await awsS3Manager.uploadDirectoryToS3({
    bucketName: s3Credentials.bucket_name,
    bucketKeyPath: s3Credentials.basepath,
    targetDirectoryPath: resultDir,
    acl: s3Credentials.acl
});
await this.cupixApi.pointcloud.checkUploading(cpPointcloud.id);

그런데 uploadDirectoryToS3 의 per-file 처리는 다음과 같이 예외를 삼킨다.

applications/agents/packages/base/src/manager/aws-s3.manager.ts:130-159typescript
const uploadChunk = async (filesChunk: string[]) => {
    await Promise.all(filesChunk.map(async (filePath) => {
        try {
            await this.checkToken();
            if (this.s3 == undefined) {
                logger.error('AwsS3Manager::uploadDirectoryToS3 | s3 is not initialized');
                return;
            }
            // ...
            await this.s3.upload({ /* ... */ }).promise();
            uploadedFileCount++;
            logger.silly('AwsS3Manager::uploadDirectoryToS3 | done: %s, uploaded: %d/%d', bucketKey, uploadedFileCount, filesToUpload.length);
        } catch (error) {
            logger.error('AwsS3Manager::uploadDirectoryToS3 | failed: %s', filePath, error);
        }
    }));
};

이 catch 의 로그 호출은 commit c1b797205 (TSLA-6555 refactor: remove stringify error, 2025-11-03) 에서 의도적으로 JSON.stringify(error) 를 제거하면서 다음과 같이 바뀌었다.

applications/agents/packages/base/src/manager/aws-s3.manager.ts:156 (commit c1b797205)
-logger.error('AwsS3Manager::uploadDirectoryToS3 | failed: %s - %s', filePath, JSON.stringify(error));+logger.error('AwsS3Manager::uploadDirectoryToS3 | failed: %s', filePath, error);

기대 동작: winston errorSafeFormat 이 splat 의 Error 를 {name, message, stack} 으로 변환해 로그 메타에 실어준다.

실제 동작: 포맷 문자열에 %s placeholder 가 1 개뿐이라 errorwinston.format.splat() 에 의해 메시지로 합쳐지지 않고 info[Symbol.for('splat')] 또는 numeric key 로 남는다. JSON 트랜스포트의 filterNumericKeys 가 숫자 키를 제거하고, Symbol 키는 JSON.stringify 가 직렬화하지 않으므로 결과 메시지에는 filePath 만 남는다.

applications/agents/packages/utils/src/cplogger.ts:170-183typescript
winston.format.printf((info) => {
    const { level, message, label, timestamp, stack, ...rest } = info;
    const cleanRest = filterNumericKeys(rest);

    return JSON.stringify({
        timestamp,
        level,
        label,
        message,
        ...(stack ? { stack } : {}),
        ...cleanRest
    });
})

또한 uploadDirectoryToS3 는 실패 카운트나 failed: string[] 를 누적해 throw 하는 로직이 없다.

applications/agents/packages/base/src/manager/aws-s3.manager.ts:161-167typescript
for (let i = 0; i < filesToUpload.length; i += Constants.AwsS3MaxTransferSize) {
    const filesChunk = filesToUpload.slice(i, i + Constants.AwsS3MaxTransferSize);
    await uploadChunk(filesChunk);
}

logger.debug('AwsS3Manager::uploadDirectoryToS3 | end');

따라서 호출자(PotreeService.uploadPotreeFiles)는 일부 파일이 실패했는지 알 수 없으며, handlingMessageErrors 도 호출되지 않는다.

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-any-potree-agent "125328"

해당 시간대 로그 시퀀스 (KST):

text
2026-06-24 15:04:56  info   PotreeService::runByMessage | id: 125328
2026-06-24 15:06:11  error  AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/125328/potree/octree.bin
2026-06-24 15:06:12  info   PotreeService::cleanUpAnythingRelatedModel | path: /tmp/workspace/125328

핵심 관찰:

  1. handlingMessageErrors 메시지가 같은 timestamp 에 존재하지 않는다 (다른 시간대 실패 사례에서는 항상 짝지어 등장 — 아래 비교).
  2. failed: <path> 메시지에 에러 본문이 전혀 들어가 있지 않다.

비교 — downloadFile 실패 시에는 에러 본문이 정상 직렬화되어 함께 기록된다.

text
2026-06-24 15:43:01  error  PotreeService::downloadFile | path: /tmp/workspace/1170854/1170854.cpc, error: {"errno":-104,"code":"ECONNRESET","syscall":"read"}
2026-06-24 15:43:01  error  PotreeService::handlingMessageErrors | Error and message object - {"error":{"errno":-104,"code":"ECONNRESET","syscall":"read"},"sqsMessage":{"MessageId":"36712d98-..."}}

downloadFile 의 catch 는 JSON.stringify(err) 를 직접 포맷 문자열에 넣고 reject 로 전파한다 — 그래서 본문도 남고 handlingMessageErrors 도 호출된다. 대조적으로 uploadDirectoryToS3 의 per-file catch 는 둘 다 깨져 있다.

추가 검색 — 동일 cluster 의 다른 발생 사례를 7일 범위에서 찾았으나 octree.bin 메시지는 이 한 건만 존재.

text
service:cupixworks-any-potree-agent "octree.bin"  → 1 hit

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 uploadDirectoryToS3 의 per-file catch 가 예외를 swallow 하여 디렉터리 업로드 실패가 호출자에게 전파되지 않고, 로그 포맷 인자 불일치로 에러 본문이 사라졌다. aws-s3.manager.ts:155-157 의 catch 블록이 throw 하지 않음; uploadDirectoryToS3 끝에 누적 실패 검사가 없음 (line 161-167); 같은 timestamp 에 handlingMessageErrors 미발생; downloadFile 실패는 대조적으로 본문 포함 + handlingMessageErrors 호출됨; commit c1b797205 에서 %s - %s%s 로 placeholder 가 줄어든 변경 확인. Confirmed
H2 AWS S3 자체 장애 (eu-central-1 outage) 동일 시간대 dep:s3-* 인시던트 없음 (status-board: active null, recent empty); 다른 pointcloud 업로드 에러가 동일 시간대에 폭증하지 않음 (7일 검색에서 octree.bin 메시지는 1건뿐). 단일 객체(octree.bin)만 실패 — 광역 장애 패턴 아님. Rejected
H3 S3 credential 만료 (checkToken 실패) checkTokenuploadChunk 의 try 안에서 호출되어 같은 catch 에 잡힌다. 가능한 시나리오. 만약 credential 문제라면 동일 디렉터리의 다른 파일 업로드도 같은 catch 에서 줄줄이 실패해 다수의 failed: ... 로그가 나와야 한다. 실제로는 octree.bin 1건만 기록 — 다른 파일들은 성공한 것으로 추정. Rejected (단독 원인으로는 부적합)
H4 특정 파일(octree.bin) 의 일시적인 S3 PutObject 에러 (예: 5xx, throttling, AccessDenied 단발) octree.bin 만 단일로 실패 — 개별 객체 단위 transient 문제와 부합. 에러 본문이 로그에서 사라져서 실제 status code 를 확인할 수 없음 — H1 의 로그 손실 때문에 검증 불가. Inconclusive (로그가 사라져 확정 불가; H1 이 진짜 root cause)

Fix Recommendation#

즉시 조치 (Critical)#

  • 파일: applications/agents/packages/base/src/manager/aws-s3.manager.ts:155-157
  • 방향: catch 블록의 logger.error 포맷 문자열 placeholder 수를 인자 수와 맞춰서 에러 본문이 Datadog 에 남도록 한다. errorSafeFormat 이 Error 객체를 변환하도록 두 번째 %s 자리에 error 를 직접 넣거나, JSON.stringify(error) 대신 winston 의 메타 필드로 넘긴다 (예: logger.error('AwsS3Manager::uploadDirectoryToS3 | failed: %s, error: %s', filePath, error)). 정확한 구현은 cplogger.tserrorSafeFormat 동작과 일관성을 맞춰 결정.
  • 파일: applications/agents/packages/base/src/manager/aws-s3.manager.ts:130-167
  • 방향: per-file 실패를 누적하여 함수 종료 시 한 번이라도 실패가 있었다면 throw 한다. (예: const failures: { filePath: string; error: unknown }[] = [] 누적 후 if (failures.length > 0) throw new Error(...).) 이렇게 하면 PotreeService.uploadPotreeFileshandlingMessageErrors 경로로 들어가고 SQS 메시지 재처리가 가능해진다.
  • 파일: applications/agents/packages/cupix-tesla-potree-agent/src/potree-service.ts:378
  • 방향: 위 변경이 적용되면 checkUploading 은 자동으로 성공 경로에서만 호출된다 — 추가 수정 불필요.

단기 개선 (1주 이내)#

  • pointcloud 125328 의 현재 상태를 확인하고 (potree_stateUploaded 인지), octree.bin 이 실제 S3 에 존재하는지 점검. 누락이라면 재처리 큐에 넣는다.
  • uploadDirectoryToS3 와 비슷한 패턴이 다른 매니저(AwsS3Manager.uploadFileStreamToS3 외에 다른 agent 코드)에 있는지 grep 으로 점검. catch 블록에서 %s placeholder 수와 인자 수가 일치하는지 일괄 검토.
  • cplogger.ts 의 winston printf 가 splat 에 남은 Error 를 자동으로 메시지 끝에 추가하도록 (예: Symbol.for('splat') 의 잔여 Error 를 message 에 append) 보강.

장기 개선 (재발 방지)#

  • 디렉터리 업로드 같은 fan-out 작업은 항상 "all-or-nothing" 의미론을 갖도록 정책화. 부분 실패 시 호출자에게 명시적으로 알리지 않으면 데이터 정합성 문제가 잠재화된다.
  • ESLint 규칙 또는 wrapper 로깅 헬퍼를 도입해 포맷 문자열의 %s/%d 개수와 가변 인자 수가 일치하는지 강제. 현 코드베이스에서 동일 결함이 반복될 가능성이 크다.

Monitoring#

  • 디렉터리 단위 업로드 실패 감지: per-file 실패 카운트가 함수 종료 시점에 0 보다 큰 경우 별도 metric/log 발생.
  • uploadDirectoryToS3 | failed: 로그 발생 빈도 추적.

Datadog timeseries 쿼리 예시:

text
sum:trace.agent.errors{service:cupixworks-any-potree-agent,resource_name:uploadDirectoryToS3}.as_count()
text
count:logs{service:cupixworks-any-potree-agent "AwsS3Manager::uploadDirectoryToS3 | failed:"}.rollup(sum, 300)
  • pointcloud potree_state=Uploaded 인데 viewer 측에서 octree.bin 404 가 발생하는 경우 사후 검증 알림을 붙이면 부분 업로드 누락을 사용자 영향 전에 잡을 수 있다.

Risk Assessment#

  • Risk level: medium — 단발 발생이지만 root cause 가 silent data loss(잘못된 성공 마킹) 가능성을 내포한다. 큰 pointcloud 일수록 동일 패턴 재현 시 영향이 크다.
  • 예상 복잡도: standard — catch 블록 수정 + 누적 실패 throw 로직 추가 + 호출자 경로 동작 확인.