VoxelWriter — async/sync stream lifecycle race condition
RCA: VoxelService::run ENOENT on capture_682011_voxels.csv
Error Log#
VoxelService::run | end - error: "ENOENT: no such file or directory, stat '/tmp/workspace/capture/682011/raw/capture_682011_voxels.csv'"
Impact#
- Service:
cupixworks-any-voxel-agent - 발생 횟수: 1
- 최초 발생: 2026-04-16T13:41:01.717Z
- 최근 발생: 2026-04-16T13:41:01.717Z
Root Cause Summary#
Capture 682011은 pano 수가 매우 적어 (reconstruction-validator가 "All cluster's pano count is under 20"으로 판정) voxel 계산 결과 voxelCount: 0을 반환했다. VoxelWriter.writeVoxelStream()은 zero-voxel 케이스를 처리하지 않아, mergeSortedFiles()에 빈 파일 배열이 전달되고 즉시 리턴된다. 이후 writeStream.end()와 writeStream.close()가 연속 호출되는데, stream의 비동기 open/flush가 완료되기 전에 fs.statSync(filePath).size (line 34)가 동기적으로 실행되어 ENOENT 에러가 발생한다. 정상적인 voxel 계산(voxelCount > 0)에서는 mergeSortedFiles의 I/O 처리 시간이 stream이 파일을 생성하고 flush할 충분한 시간을 제공하기 때문에 이 문제가 발생하지 않는다.
Technical Analysis#
Code Path#
- Entry point:
voxel-service.ts:45—VoxelService.run()시작 - Step 1:
voxel-service.ts:49-54— API에서 capture 데이터 로드,CPRealityCapture생성 시setDefaultPath()가/tmp/workspace/capture/682011/raw/디렉토리 생성 - Step 2:
voxel-service.ts:56→voxel.manager.ts:30-46—calculateVoxels()호출, capture의 pano 데이터로 voxel 계산 - Failure trigger:
voxel.manager.ts:43-44—voxelCount: 0, numNodes: NaN, numLeaves: NaN반환. Pano 수 부족으로 유효한 voxel이 생성되지 않음 - Step 3:
voxel-service.ts:58→voxel.manager.ts:48-57—saveRawVoxels()호출 - Step 4:
voxel.manager.ts:55→voxel-writer.ts:8-35—writeVoxelStream()실행
// voxel-writer.ts:8-35
writeVoxelStream = async (filePath: string, voxelMap: VoxelCalculator.VoxelMap, params: VoxelCalculator.Params, includesMeta = true): Promise<void> => {
const sortedFiles = this.writeVoxelStreamFromMap(voxelMap, params); // voxelCount=0 → 빈 배열 반환
const writeStream = fs.createWriteStream(filePath, { flags: 'w', highWaterMark: this._BUFFER_SIZE });
if (includesMeta) writeStream.write(this.metaString(params)); // includesMeta=false (raw), 스킵
writeStream.write(this.header); // 헤더만 버퍼에 기록
// ...
await mergeSortedFiles<VoxelCalculator.Voxel>( // sortedFiles=[] → 즉시 리턴
sortedFiles, writeStream, this.deserialize, this.serialize, this.compareFn, '\n'
);
clearInterval(sortingLogs);
writeStream.end(); // 비동기 flush 시작
writeStream.close(); // 즉시 stream 파괴
// ↓ 비동기 open/flush 완료 전에 동기 stat 호출
logger.debug('...fileSize: %d', filePath, fs.statSync(filePath).size); // ENOENT 발생 지점
};
- Failure point:
voxel-writer.ts:34—fs.statSync(filePath).size
writeVoxelStreamFromMap()은 빈 voxelMap을 순회하므로 sorted temp 파일을 하나도 생성하지 않고 빈 배열을 반환한다:
// voxel-writer.ts:37-71
protected writeVoxelStreamFromMap = (voxelMap: VoxelCalculator.VoxelMap, params: VoxelCalculator.Params): Array<string> => {
let voxelCount = 0;
const lines: Array<VoxelCalculator.Voxel> = [];
const outputFiles: Array<string> = [];
voxelMap.forEach((mapYZW, x) => { // voxelMap이 비어있으면 순회하지 않음
// ...
});
// lines.length === 0이므로 마지막 flush도 스킵
return outputFiles; // 빈 배열
};
mergeSortedFiles()는 빈 배열에 대해 즉시 리턴한다:
// large-sort.ts:420-427
export async function mergeSortedFiles<TValue>(
files: string[],
// ...
): Promise<void> {
if (files.length === 0) return; // 즉시 리턴, stream에 데이터 기록 없음
// ...
}
결과적으로 createWriteStream의 비동기 파일 open이 완료되기 전에 전체 흐름이 statSync까지 도달하여 ENOENT가 발생한다. 정상 케이스(voxelCount > 0)에서는 mergeSortedFiles의 I/O 작업 시간 동안 stream의 open이 자연스럽게 완료된다.
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-any-voxel-agent @environment:production 682011
Capture 682011의 voxel agent 로그 (총 3건):
2026-04-16T13:41:01.582Z [info] CPRealityCapture::setCaptureVoxelsParams | capture ID: 682011 - voxelSize: 1, panoIndoorVoxelEffectiveRange: 5, panoOutdoorVoxelEffectiveRange: 5, pointcloudPotreeLevelThreshold: 3
2026-04-16T13:41:01.709Z [info] VoxelManager::calculateVoxels | finished - capture ID: 682011, voxelCount: 0, numNodes: NaN, numLeaves: NaN
2026-04-16T13:41:01.717Z [error] VoxelService::run | end - error: "ENOENT: no such file or directory, stat '/tmp/workspace/capture/682011/raw/capture_682011_voxels.csv'"
voxelCount: 0 이후 saveRawVoxels/saveXYPlaneVoxels 관련 debug 로그가 전혀 없이 8ms 만에 error가 발생 — writeVoxelStream 내부의 statSync에서 즉시 실패했음을 확인.
같은 시간대 성공한 capture들은 모두 voxelCount > 0:
Capture 682017: voxelCount: 6343 → 성공
Capture 681982: voxelCount: 69797 → 성공
Capture 681789: voxelCount: 504976 → 성공
Reconstruction validator 로그 (capture 682011 처리 과정):
service:cupixworks-any-voxel-agent @environment:production
2026-04-16T13:39:15Z [info] reconstruction-validator: Validation FAIL: "All cluster's pano count is under 20. Skip densemapper execution"
이 로그는 capture 682011의 pano 수가 매우 적어 densemapper 실행조차 스킵되었음을 확인. Voxel 계산에서도 유효한 포인트가 없어 0개의 voxel이 반환된 원인.
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
applications/agents/packages/cupix-tesla-voxel-agent/src/voxel-service.ts:56-58 calculateVoxels()반환값의voxelCount를 확인하여 0인 경우saveRawVoxels/saveXYPlaneVoxels/uploadRawVoxels/uploadXYPlaneVoxels를 스킵하고 early return 처리. Voxel이 0개이면 CSV 파일을 생성하거나 업로드할 필요가 없다.VoxelState를Error가 아닌 적절한 완료 상태 (예:Done또는Empty)로 설정하여 불필요한 에러 알림을 방지.
단기 개선 (1주 이내)#
- 파일:
applications/agents/packages/cupix-tesla-voxel-agent/src/util/voxel-writer.ts:31-34 writeStream.end()이후writeStream.close()호출을 제거하거나,end이벤트를 await한 뒤statSync를 호출하도록 수정.end()는 이미 stream을 닫으므로close()는 불필요하며, 동기statSync는 stream flush 완료를 보장하지 않는다.writeVoxelStream에서 빈 voxelMap에 대한 명시적 guard 추가 (sorted files가 비어있으면 빈 파일 생성 또는 스킵).
장기 개선 (재발 방지)#
- Voxel agent에 pano 수 기반 사전 검증 로직 추가. Reconstruction-validator와 동일한 기준 (pano count < 20)으로 voxel 계산을 사전 스킵하여 불필요한 처리와 에러를 방지.
writeVoxelStream의 stream 관리를 promise 기반으로 리팩터링하여end이벤트를 명시적으로 await.
Monitoring#
- Voxel agent에서
voxelCount: 0으로 완료되는 capture 비율 모니터링:
service:cupixworks-any-voxel-agent "calculateVoxels" "voxelCount: 0"
- ENOENT 에러 재발 감지:
service:cupixworks-any-voxel-agent status:error "ENOENT" "voxels.csv"
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial —
voxelCount === 0early return 추가로 해결 가능