ES /docs

VoxelWriter — async/sync stream lifecycle race condition

RCA: VoxelService::run ENOENT on capture_682011_voxels.csv

Error Log#

Datadog Logs

text
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:45VoxelService.run() 시작
  • Step 1: voxel-service.ts:49-54 — API에서 capture 데이터 로드, CPRealityCapture 생성 시 setDefaultPath()/tmp/workspace/capture/682011/raw/ 디렉토리 생성
  • Step 2: voxel-service.ts:56voxel.manager.ts:30-46calculateVoxels() 호출, capture의 pano 데이터로 voxel 계산
  • Failure trigger: voxel.manager.ts:43-44voxelCount: 0, numNodes: NaN, numLeaves: NaN 반환. Pano 수 부족으로 유효한 voxel이 생성되지 않음
  • Step 3: voxel-service.ts:58voxel.manager.ts:48-57saveRawVoxels() 호출
  • Step 4: voxel.manager.ts:55voxel-writer.ts:8-35writeVoxelStream() 실행
typescript
// 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:34fs.statSync(filePath).size

writeVoxelStreamFromMap()은 빈 voxelMap을 순회하므로 sorted temp 파일을 하나도 생성하지 않고 빈 배열을 반환한다:

typescript
// 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()는 빈 배열에 대해 즉시 리턴한다:

typescript
// 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 검색 쿼리:

text
service:cupixworks-any-voxel-agent @environment:production 682011

Capture 682011의 voxel agent 로그 (총 3건):

text
2026-04-16T13:41:01.582Z [info] CPRealityCapture::setCaptureVoxelsParams | capture ID: 682011 - voxelSize: 1, panoIndoorVoxelEffectiveRange: 5, panoOutdoorVoxelEffectiveRange: 5, pointcloudPotreeLevelThreshold: 3
text
2026-04-16T13:41:01.709Z [info] VoxelManager::calculateVoxels | finished - capture ID: 682011, voxelCount: 0, numNodes: NaN, numLeaves: NaN
text
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:

text
Capture 682017: voxelCount: 6343 → 성공
Capture 681982: voxelCount: 69797 → 성공
Capture 681789: voxelCount: 504976 → 성공

Reconstruction validator 로그 (capture 682011 처리 과정):

text
service:cupixworks-any-voxel-agent @environment:production
text
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 파일을 생성하거나 업로드할 필요가 없다.
  • VoxelStateError가 아닌 적절한 완료 상태 (예: 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 비율 모니터링:
text
service:cupixworks-any-voxel-agent "calculateVoxels" "voxelCount: 0"
  • ENOENT 에러 재발 감지:
text
service:cupixworks-any-voxel-agent status:error "ENOENT" "voxels.csv"

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial — voxelCount === 0 early return 추가로 해결 가능