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::calculateVoxels가 voxelCount: 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#
- 2026-06-24 14:32:24 KST —
BaseService::runByMessage | id: 230110(SQS 메시지 수신) - 2026-06-24 14:32:25 KST —
CPRealityCapture::setCaptureVoxelsParams | pointcloud ID: 230110 - voxelSize: 1, potreeLevelThreshold: 3 - 2026-06-24 14:32:25 KST —
VoxelManager::calculateVoxels | finished - pointcloud ID: 230110, voxelCount: NaN, numNodes: NaN, numLeaves: NaN - 2026-06-24 14:32:25 KST —
ENOENT: no such file or directory, stat '/tmp/workspace/pointcloud/230110/raw/pointcloud_230110_voxels.csv'(cluster first/last seen)
Error Log#
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
VoxelService::run에서voxelManager.calculateVoxels(cpRealityCapture)호출:
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);
}
};
CPPointcloudVoxelCalculator.calculate가 pointcloud 분기에서 호출된다.load()가 실패하면console.warn만 하고undefined를 반환한 뒤,calculate()의 outer catch가calculated: false인 부분 응답을 반환한다 —voxelCount/numNodes/numLeaves키 자체가 없다:
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;
}
}
- 호출 측은 응답을 그대로 로깅 — 누락 필드가
%d에 의해NaN으로 포맷됨:
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;
};
- Failure point:
saveRawVoxels → writeVoxelStream마지막 줄.mergeSortedFiles([])는 즉시 반환하고,writeStream.end(); writeStream.close()직후fs.statSync가 동기 호출되는데createWriteStream의 비동기 open이 아직 완료되지 않은 상태에서 stat이 실행되어 ENOENT가 발생한다. 또한logger.debug(...)이라도 인자 평가(fs.statSync(filePath).size)는 항상 실행되므로 로그 레벨 설정과 무관하게 호출된다:
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:
export async function mergeSortedFiles<TValue>(
files: string[],
outputStream: Writable,
/* ... */
): Promise<void> {
if (files.length === 0) return;
// ...
}
- 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: ...만 남는 형태가 됐다:
- 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:
service:cupixworks-any-voxel-agent "230110"
service:cupixworks-any-voxel-agent "voxelCount: NaN"
service:cupixworks-any-voxel-agent "ENOENT"
Pointcloud 230110 시간순 로그 (KST, +09:00):
{ "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에서 관측된 사실 — 단일 모델 데이터 손상이 아닌 코드 경로상의 결함임을 뒷받침:
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 로그 수집에 잡히지 않음을 시사:
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:34가 fs.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) 코드 경로상 uploadRawVoxels는 saveRawVoxels 이후에 실행되며, saveRawVoxels의 ENOENT가 throw되면 그곳에서 catch됨 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
applications/agents/packages/cupix-tesla-voxel-agent/src/util/cppointcloud-voxel-calculator.ts:23-30, 43-46—console.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, 67—saveRawVoxels/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 규칙(ESLintno-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 위젯용):
count:logs("service:cupixworks-any-voxel-agent voxelCount: NaN").index("*").rollup("count").by("region").last("1h")
count:logs("service:cupixworks-any-voxel-agent ENOENT voxels.csv").index("*").rollup("count").by("region").last("1h")
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_state가Error로 마킹되므로 재시도가 가능.
- 예상 복잡도: standard
- 즉시 조치(로깅 교체 + 빈 응답 가드)는 2–3 라인 수정 수준.
- 단기 개선(stream finish 대기 + voxelCount 0 명시화)도 작은 패치 범위.
- 장기 개선(컨벤션 lint, validate 강화)은 별도 작업으로 분리 가능.