ES /docs

ENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxe

RCA: ENOENT stat on pointcloud voxels.csv in voxel-agent

Overview#

What Happened#

cupixworks-any-voxel-agent (production, ap-southeast-2)가 pointcloud 230110 메시지를 처리하던 중 VoxelManager::calculateVoxelsvoxelCount: NaN으로 종료된 직후 ENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxels.csv' 에러를 발생시켰다. 동일 패턴이 같은 날 다수의 pointcloud / capture에서 반복적으로 관측되므로 단발성 환경 문제가 아니라 코드 경로상의 결함이다.

Quick Facts#

Field Value
exception.class Error (ENOENT)
exception.message ENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxels.csv'
top_frame applications/agents/packages/cupix-tesla-voxel-agent/src/util/voxel-writer.ts:34
runtime Node.js (TypeScript agent), cupix-tesla-voxel-agent package
env production, region ap-southeast-2, tenant cupix

Affected Teams#

Team / Domain Error Count Impact
endeavourgroup (this cluster, pointcloud 230110) 1 Voxel state는 Error로 마킹되며, 해당 pointcloud의 voxel 산출물이 S3에 업로드되지 않음
같은 서비스 전체 (지난 24h, ENOENT voxels.csv) 20+건 (Datadog) 다수의 capture/pointcloud 처리 실패 — voxelCount: NaN을 동반함

Timeline#

  1. 2026-06-24 14:32:24 KSTBaseService::runByMessage | id: 230110 (SQS 메시지 수신)
  2. 2026-06-24 14:32:25 KSTCPRealityCapture::setCaptureVoxelsParams | pointcloud ID: 230110 - voxelSize: 1, potreeLevelThreshold: 3
  3. 2026-06-24 14:32:25 KSTVoxelManager::calculateVoxels | finished - pointcloud ID: 230110, voxelCount: NaN, numNodes: NaN, numLeaves: NaN
  4. 2026-06-24 14:32:25 KSTENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxels.csv' (cluster first/last seen)

Error Log#

Datadog Logs

text
ENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxels.csv'

Impact#

  • Service: cupixworks-any-voxel-agent
  • Team: endeavourgroup
  • 발생 횟수: 1 (해당 클러스터); 동일 fingerprint 패턴은 서비스 전반에서 지난 24h 동안 20건 이상 (voxelCount: NaN 동반 모두 동일 root cause)
  • 최초 발생: 2026-06-24 14:32:25 KST
  • 최근 발생: 2026-06-24 14:32:25 KST

Root Cause Summary#

Pointcloud 230110에 대해 CPPointcloudVoxelCalculator.load()가 내부에서 던진 예외(가장 가능성이 높은 원인은 Potree 데이터 로딩 실패 — 잘못된 URL/누락된 파일/네트워크 오류)를 console.warn(ec)로만 출력하고 undefined를 반환한다. 호출자 calculate()'Failed to load point cloud octree'를 throw하고 자신의 catch 블록에서 voxelMap: 빈 Map, calculated: false인 응답을 반환하지만, voxelCount / numNodes / numLeaves 필드를 채워주지 않는다. 이 빈 응답이 VoxelManager::saveRawVoxels → VoxelWriter::writeVoxelStream로 흘러가 빈 voxelMap에 대해 mergeSortedFiles([])가 즉시 반환하고, 동기로 호출되는 마지막 줄 fs.statSync(filePath).size가 — 비동기 createWriteStream의 파일 open이 아직 완료되지 않아 — ENOENT: ... stat ...를 throw한다. 외부 try/catch가 이 stat 에러를 잡고 VoxelService::run | error (TSLA-11891 변경 후 새 포맷)로 로깅한다. 즉, 표면 에러는 statSync ENOENT지만 진짜 원인은 Potree pointcloud 로딩 실패이며, 그 실패 사유가 어디에도 로그로 남지 않는다.

Technical Analysis#

Code Path#

Entry point: applications/agents/packages/base/src/base-service.ts:153 (BaseService::runByMessage) → VoxelService::run

  1. VoxelService::run에서 voxelManager.calculateVoxels(cpRealityCapture) 호출:
applications/agents/packages/cupix-tesla-voxel-agent/src/voxel-service.ts:45-67typescript
run = async (targetId: number, msgObject?: any): Promise<void> => {
    const targetType = msgObject.type ?? 'capture';
    try {
        const serverRealityCapture = await this.realityCaptureManager.loadRealityCapture(targetId, targetType);
        const cpRealityCapture = this.realityCaptureManager.createCPRealityCapture(serverRealityCapture, targetType);
        if (!DEBUG_MODE) await this.realityCaptureManager.updateVoxelState(TESLA.VoxelState.Aggregating);
        await this.realityCaptureManager.loadEntityParameters(cpRealityCapture);
        await this.realityCaptureManager.loadSubModels(cpRealityCapture);

        const result = await this.voxelManager.calculateVoxels(cpRealityCapture);

        await this.voxelManager.saveRawVoxels(result, cpRealityCapture.rawVoxelFilePath);
        await this.voxelManager.saveXYPlaneVoxels(result, cpRealityCapture.voxelFilePath);
        // ...
    } catch (error: any) {
        logger.error('VoxelService::run | error', error);
        if (!DEBUG_MODE) await this.realityCaptureManager.updateVoxelState(TESLA.VoxelState.Error);
    }
};
  1. CPPointcloudVoxelCalculator.calculate가 pointcloud 분기에서 호출된다. load()가 실패하면 console.warn만 하고 undefined를 반환한 뒤, calculate()의 outer catch가 calculated: false인 부분 응답을 반환한다 — voxelCount/numNodes/numLeaves 키 자체가 없다:
applications/agents/packages/cupix-tesla-voxel-agent/src/util/cppointcloud-voxel-calculator.ts:13-47typescript
async calculate(params: VoxelCalculator.Params): Promise<VoxelCalculator.Response> {
    try {
        const pco = await this.load(params);
        if (pco == null) {
            throw new Error('Failed to load point cloud octree');
        }
        const res = this.calculateOccupiedVoxels(pco, params);
        return res;
    } catch (ec) {
        console.warn(ec);
        return {
            voxelMap: new Map<number, Map<number, Map<number, number>>>(),
            params: params,
            calculated: false
        };
    }
}

private async load(params: VoxelCalculator.Params): Promise<EnginePotreeV2.PointCloudOctree | undefined> {
    try {
        const pco = await this.potree_.loadPointCloud(
            `${params.filename}`,
            url => `${params.baseURL}${url}`,
            true
        );
        return pco;
    } catch (ec) {
        console.warn(ec);
        return undefined;
    }
}
  1. 호출 측은 응답을 그대로 로깅 — 누락 필드가 %d에 의해 NaN으로 포맷됨:
applications/agents/packages/cupix-tesla-voxel-agent/src/manager/voxel.manager.ts:30-46typescript
calculateVoxels = async (cpRealityCapture: CPRealityCapture): Promise<VoxelCalculator.Response> => {
    const params: VoxelCalculator.Params = cpRealityCapture.voxelParams;
    if (cpRealityCapture.isCapture) {
        this._voxelResult = CPCaptureOccupiedVoxelUtils.calculate(cpRealityCapture, params);
    } else if (cpRealityCapture.isPointcloud) {
        this._voxelResult = await new CPPointcloudVoxelCalculator().calculate(params);
    } else {
        throw new Error('unknown target type');
    }

    logger.info('VoxelManager::calculateVoxels | finished - %s ID: %d, voxelCount: %d, numNodes: %d, numLeaves: %d'
        , cpRealityCapture.modelType, cpRealityCapture.id
        , this._voxelResult.voxelCount, this._voxelResult.numNodes, this._voxelResult.numLeaves);
    return this._voxelResult;
};
  1. Failure point: saveRawVoxels → writeVoxelStream 마지막 줄. mergeSortedFiles([])는 즉시 반환하고, writeStream.end(); writeStream.close() 직후 fs.statSync가 동기 호출되는데 createWriteStream의 비동기 open이 아직 완료되지 않은 상태에서 stat이 실행되어 ENOENT가 발생한다. 또한 logger.debug(...)이라도 인자 평가(fs.statSync(filePath).size)는 항상 실행되므로 로그 레벨 설정과 무관하게 호출된다:
applications/agents/packages/cupix-tesla-voxel-agent/src/util/voxel-writer.ts:8-35typescript
writeVoxelStream = async (filePath: string, voxelMap: VoxelCalculator.VoxelMap, params: VoxelCalculator.Params, includesMeta = true): Promise<void> => {
    logger.debug('VoxelWriter::writeVoxelStream | begin - filePath: %s', filePath);

    const sortedFiles = this.writeVoxelStreamFromMap(voxelMap, params);
    // empty voxelMap → sortedFiles === []

    const writeStream = fs.createWriteStream(filePath, { flags: 'w', highWaterMark: this._BUFFER_SIZE });
    if (includesMeta) writeStream.write(this.metaString(params));
    writeStream.write(this.header);

    await mergeSortedFiles<VoxelCalculator.Voxel>(
        sortedFiles, writeStream, this.deserialize, this.serialize, this.compareFn, '\n'
    );
    // mergeSortedFiles returns immediately when files.length === 0:
    //   export async function mergeSortedFiles<TValue>(files, ...) {
    //       if (files.length === 0) return;   // large-sort.ts:427
    //       ...
    //   }

    writeStream.end();
    writeStream.close();

    logger.debug('VoxelWriter::writeVoxelStream | voxel file written to: %s, fileSize: %d'
        , filePath, fs.statSync(filePath).size);  // ← ENOENT throws here
};

mergeSortedFiles early-return:

applications/agents/packages/cupix-tesla-voxel-agent/src/util/large-sort.ts:420-442typescript
export async function mergeSortedFiles<TValue>(
    files: string[],
    outputStream: Writable,
    /* ... */
): Promise<void> {
    if (files.length === 0) return;
    // ...
}
  1. Outer catch in VoxelService::run가 ENOENT를 잡아 logger.error('VoxelService::run | error', error)로 기록한다. 최근 변경(TSLA-11891, 커밋 5dfdd8690, 2026-02-13)에서 logger.error('VoxelService::run | end - error: %s', JSON.stringify(error.message))logger.error('VoxelService::run | error', error)로 바뀌어, log4js가 두 번째 인자(Error 객체) 자체를 출력 메시지로 사용하면서 cluster의 representative error에 prefix 없이 ENOENT: ...만 남는 형태가 됐다:
applications/agents/packages/cupix-tesla-voxel-agent/src/voxel-service.ts (commit 5dfdd8690)
-    logger.error('VoxelService::run | end - error: %s', JSON.stringify(error.message));+    logger.error('VoxelService::run | error', error);

기대 동작 vs 실제 동작:

  • 기대: pointcloud Potree 로딩 실패 시 명시적 에러 로그 + Voxel State = Error 갱신 + 후속 파일 IO 미진행.
  • 실제: 로딩 실패 사유는 console.warn으로만 남아 Datadog에 도달하지 않고, 빈 voxel 응답이 그대로 IO 단계까지 진행되어 표면적으로는 "csv 파일이 없음(stat ENOENT)"로 보이는 2차 에러가 기록된다.

Log Evidence#

Used Datadog queries:

text
service:cupixworks-any-voxel-agent "230110"
text
service:cupixworks-any-voxel-agent "voxelCount: NaN"
text
service:cupixworks-any-voxel-agent "ENOENT"

Pointcloud 230110 시간순 로그 (KST, +09:00):

json
{ "timestamp": "2026-06-24 14:32:24", "status": "info",  "message": "BaseService::runByMessage | id: 230110" }
{ "timestamp": "2026-06-24 14:32:25", "status": "info",  "message": "CPRealityCapture::setCaptureVoxelsParams | pointcloud ID: 230110 - voxelSize: 1, panoIndoorVoxelEffectiveRange: 5, panoOutdoorVoxelEffectiveRange: 5, pointcloudPotreeLevelThreshold: 3" }
{ "timestamp": "2026-06-24 14:32:25", "status": "info",  "message": "VoxelManager::calculateVoxels | finished - pointcloud ID: 230110, voxelCount: NaN, numNodes: NaN, numLeaves: NaN" }
{ "timestamp": "2026-06-24 14:32:25", "status": "error", "message": "ENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxels.csv'" }

voxelCount: NaN 패턴이 동일한 시간대(2026-06-24 KST)에 다수의 model id에서 관측된 사실 — 단일 모델 데이터 손상이 아닌 코드 경로상의 결함임을 뒷받침:

text
2026-06-24 15:30:00  pointcloud ID: 230174  voxelCount: NaN
2026-06-24 14:32:25  pointcloud ID: 230110  voxelCount: NaN
2026-06-24 12:55:01  pointcloud ID: 230076  voxelCount: NaN
2026-06-24 12:45:56  pointcloud ID: 230072  voxelCount: NaN
2026-06-24 12:10:09  pointcloud ID: 230070  voxelCount: NaN
2026-06-24 12:03:13  pointcloud ID: 230062  voxelCount: NaN
2026-06-24 11:58:13  pointcloud ID: 230060  voxelCount: NaN
... (지난 24h 안에 동일 패턴 다수)

Datadog 검색 결과에 Failed to load point cloud 또는 loadPointCloud 키워드를 가진 로그는 0건console.warn@agents/utils logger 파이프라인을 거치지 않아 production 로그 수집에 잡히지 않음을 시사:

text
service:cupixworks-any-voxel-agent "Failed to load point cloud"   → 0 logs
service:cupixworks-any-voxel-agent "loadPointCloud"               → 0 logs

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 CPPointcloudVoxelCalculator.load()가 Potree 데이터 로딩에 실패하고 그 예외는 console.warn만 남긴 뒤 빈 응답이 IO 단계로 흘러가 statSync가 ENOENT를 던진다 (a) voxelCount: NaN 로그는 calculate() catch 분기의 응답 (voxelCount 필드 부재)에서만 발생 — code path 일치. (b) 동일한 NaN 패턴이 24h 동안 다수 모델에서 반복. (c) loadPointCloud 관련 로그가 Datadog에 0건 — console.warn 경로와 일치. (d) voxel-writer.ts:34fs.statSync(filePath)를 호출하는 유일한 동기 stat 지점이고, 메시지의 파일 경로가 cpRealityCapture._rawVoxelFilePath와 정확히 일치 (/tmp/workspace/pointcloud/{id}/raw/pointcloud_{id}_voxels.csv, cpreality-capture.ts:111-113) Confirmed
H2 /tmp/workspace/... 파일이 외부 프로세스(cron, cleanup) 또는 컨테이너 stop으로 삭제되어 ENOENT가 발생 파일이 실제 없음 (a) 단일 모델당 동일 timestamp 안에서 발생 — 외부 cleanup의 timing과 맞지 않음. (b) 다수의 모델에서 동시 발생 — 모두 같은 청소 윈도에 걸렸다고 보기 어려움. (c) NaN voxelCount가 먼저 관측됨 — 정상 처리가 시작되지 않은 상태 Rejected
H3 fs.createWriteStream 비동기 open이 lazy하므로 writeStream.end() 후 즉시 statSync를 호출하면 race로 ENOENT가 가능하다 (logger.debug라도 인자 평가는 항상 일어남) Node fs.createWriteStream은 lazy open을 사용. JS 함수 호출 시 인자는 항상 evaluation. 정상 케이스에서는 mergeSortedFiles가 비동기 I/O를 await하는 동안 open이 완료되므로 race가 거의 보이지 않음. 본 사건은 H1의 빈 voxelMap → mergeSortedFiles 즉시 return으로 race window가 발생한 케이스 Confirmed (보조 메커니즘)
H4 S3 업로드 단계(uploadRawVoxels/uploadFileStreamToS3)에서 ENOENT가 발생 업로드 대상이 같은 csv 파일 (a) 메시지 키워드가 stat인데 해당 경로의 SDK 호출은 createReadStream을 사용해 ENOENT: ... open ...이 되어야 함. (b) 코드 경로상 uploadRawVoxelssaveRawVoxels 이후에 실행되며, saveRawVoxels의 ENOENT가 throw되면 그곳에서 catch됨 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • applications/agents/packages/cupix-tesla-voxel-agent/src/util/cppointcloud-voxel-calculator.ts:23-30, 43-46console.warn(ec)@agents/utils.logger.error로 교체. log4js 컨벤션(logger.error('… | error', error))에 따라 Error 객체를 그대로 전달해 stack/message가 Datadog에 수집되게 한다. 이 단일 변경만으로도 production에서 실제 실패 사유(예: 404, ETIMEDOUT, 인증 실패 등)를 즉시 파악할 수 있게 된다.
  • applications/agents/packages/cupix-tesla-voxel-agent/src/manager/voxel.manager.ts:55, 67saveRawVoxels / saveXYPlaneVoxels 진입 시 result.calculated === false이면 IO를 건너뛰고 즉시 throw하거나 명시적으로 voxel state를 Error로 갱신하도록 분기 추가. 빈 응답을 IO까지 흘려보내는 것이 stat ENOENT의 직접 원인이다.

단기 개선 (1주 이내)#

  • applications/agents/packages/cupix-tesla-voxel-agent/src/util/voxel-writer.ts:34 — 디버그 로그 안의 fs.statSync(filePath).size를 안전한 헬퍼(CPUtils.getFileSize는 try/catch로 -1을 반환함, utils/src/cputils.ts:118-125)로 교체하거나, 스트림 완료('finish'/'close' 이벤트)를 await한 뒤 stat을 수행하도록 변경. 호출자 측에서 race가 발생하지 않도록 한다.
  • cppointcloud-voxel-calculator.ts의 두 단계 catch(load() + calculate()) 사이에서 응답 객체에 최소한 voxelCount: 0, numNodes: 0, numLeaves: 0을 채워 NaN 로그를 제거 — 향후 운영 시 진짜 0 voxel 케이스와 실패 케이스를 구분하려면 calculated 플래그를 호출자가 명시적으로 확인하도록 강제.

장기 개선 (재발 방지)#

  • 모든 agent 패키지에서 console.log/warn/error 사용을 lint 규칙(ESLint no-console)으로 금지하고 @agents/utils.logger만 허용. 본 사건은 Potree 로딩 실패 시 console.warn만 사용해 Datadog 수집에서 누락된 사례 — 단일 패키지 결함이 아니라 컨벤션 위반이 운영에 미친 영향이 큼.
  • Potree pointcloud 입력 검증을 voxel-agent 진입부에서 수행 (potreeUrl, entry_filename, S3 readability) — 현재 CPRealityCapture::validate (cpreality-capture.ts:137)는 potreeUrl 존재만 검증함. 실제 fetch 단계 실패를 미리 빠르게 끊어내는 health check가 필요.

Monitoring#

  • 추가할 메트릭/알림:
    • voxelCount: NaN 로그 분당 1회 이상 발생 시 P2 알림
    • VoxelService::run | error 분당 1회 이상 발생 시 P2 알림
    • 단기 fix 이후에는 새로 도입할 CPPointcloudVoxelCalculator::load | error 로그를 별도 위젯으로 트래킹

Datadog timeseries 쿼리 (release dashboard 위젯용):

text
count:logs("service:cupixworks-any-voxel-agent voxelCount: NaN").index("*").rollup("count").by("region").last("1h")
text
count:logs("service:cupixworks-any-voxel-agent ENOENT voxels.csv").index("*").rollup("count").by("region").last("1h")
text
count:logs("service:cupixworks-any-voxel-agent VoxelService::run | error").index("*").rollup("count").by("region").last("1h")

Risk Assessment#

  • Risk level: medium
    • 영향: pointcloud / capture의 voxel 산출물이 생성/업로드되지 않아 후속 파이프라인(예: 3D 재구성 결과의 voxel 기반 다운스트림 처리)에 결손 발생. 다수 모델이 24h 안에 반복적으로 실패하고 있으므로 단순 단발 이슈는 아니다.
    • 데이터 손상 없음 (write 자체가 빈 voxelMap → 빈 파일/미생성에 해당). 운영 측면에서 voxel_stateError로 마킹되므로 재시도가 가능.
  • 예상 복잡도: standard
    • 즉시 조치(로깅 교체 + 빈 응답 가드)는 2–3 라인 수정 수준.
    • 단기 개선(stream finish 대기 + voxelCount 0 명시화)도 작은 패치 범위.
    • 장기 개선(컨벤션 lint, validate 강화)은 별도 작업으로 분리 가능.