ES /docs

download floorplan image failed

RCA: download floorplan image failed

Overview#

What Happened#

2026-07-15 11:28:46 KST 에 cupixworks-capture-refinement-arm-instance (프로덕션 us-west-2, tenant cupix) 의 capture refinement job(id 1197619) 이 floorplan 이미지 다운로드 단계에서 실패했다. RefinementService::loadFloorplanCPFloorplan.downloadImage 로부터 false 를 받아 Error('download floorplan image failed') 를 throw 했고, 원인은 로컬 저장 경로에 슬래시가 포함된 파일 확장자(rvt-site/lvl 1) 가 들어가 존재하지 않는 하위 디렉터리에 write 를 시도하면서 발생한 ENOENT 였다.

Quick Facts#

Field Value
exception.class Error
exception.message download floorplan image failed
top_frame packages/cupix-capture-refinement-agent/src/refinement-service.ts:209
underlying_error ENOENT: open /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1
env production, us-west-2
tenant cupix
team rsconstruction
job_id 1197619
floorplan_id 83272
capture_id (workspace) 731741

Affected Teams#

Team / Domain Error Count Impact
rsconstruction / capture refinement 1 단일 capture refinement job (1197619) 이 실패해 해당 capture 의 refinement 결과가 산출되지 않음

Timeline#

  1. 2026-07-15 11:27:50 KST — Refinement 인스턴스가 부팅되고 RefinementService::init, authenticate, run 이 실행됨. JobManager::loadJob 이 job id 1197619 를 로드.
  2. 2026-07-15 11:27:53 KST — capture 리소스/video 파일 관련 warn 로그(video filepath not found - 693974.insv) 다수 기록. (별개 이슈, 이번 실패의 직접 원인은 아님)
  3. 2026-07-15 11:28:46 KSTCPFloorplan.downloadImageENOENT 로 실패, RefinementService::loadFloorplandownload floorplan image failed 를 error 로 로깅하며 throw.

Error Log#

Datadog Logs

text
download floorplan image failed

Impact#

  • Service: cupixworks-capture-refinement-arm-instance
  • Team: rsconstruction
  • 발생 횟수: 1
  • 최초 발생: 2026-07-15 11:28:46 KST
  • 최근 발생: 2026-07-15 11:28:46 KST

Blast radius 는 단일 job. 다만 원인이 특정 floorplan record 의 데이터 형태 (file_extension 또는 meta.name 이 슬래시를 포함) 이므로, 동일한 floorplan 을 참조하는 capture refinement job 은 재시도 시에도 계속 실패한다. 다른 BIM/Revit 파생 floorplan 도 같은 패턴이면 재발 가능성 있음.

Root Cause Summary#

CPFloorplan.setDefaultLocalPaths 에서 로컬 이미지 파일 경로를 path.join(captureWorkspaceDirPath, floorplan_${id}.${originalImageFileExt}) 로 구성한다. 이번 사건의 floorplan 83272 는 확장자 부분이 슬래시와 공백을 포함하는 값(rvt-site/lvl 1) 으로 결정되어 최종 경로가 /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1 이 되었다. 이 값은 확장자가 아니라 하위 디렉터리를 만들어야 하는 문자열이지만, 코드는 파일명만 가정하고 fs.writeFileSync 를 호출하기 때문에 floorplan_83272.rvt-site 디렉터리가 존재하지 않아 ENOENT 로 실패했다. downloadImage 는 예외를 catch 해 false 만 반환하므로 상위 loadFloorplan 에서는 원인 정보 없이 일반 실패 문자열만 throw 된다.

Technical Analysis#

Code Path#

  • Entry point: packages/cupix-capture-refinement-agent/src/refinement-service.ts:192 (loadFloorplan)
  • Path 생성: packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:35 (setDefaultLocalPaths)
  • Failure point: packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:67 (fs.writeFileSync on unresolved subdirectory)

loadFloorplan 은 서버에서 floorplan 을 조회하고 CPFloorplan 생성 시 로컬 경로를 계산한 뒤 downloadImage 를 호출한다. downloadImage 가 false 를 반환하면 error log 를 남기고 Error('download floorplan image failed') 를 throw 하여 상위로 전파한다.

packages/cupix-capture-refinement-agent/src/refinement-service.ts:192-214typescript
private loadFloorplan = async (cpCapture: CPCapture): Promise<void> => {
    logger.debug('RefinementService::loadFloorplan | begin');
    const floorplanId = cpCapture.cpCluster?.getUsedFloorplanIdFromMeta();
    if (floorplanId == undefined) {
        logger.error('RefinementService::loadFloorplan | end - not found floorplan id');
        this.jobManager.setErrorCode(ErrorCode.ScenemapperUtils.RefinerNotFoundFloorplan);
        throw new Error('not found floorplan id');
    }

    const srvFloorplan = await this.cupixApi.floorplan.get(floorplanId);
    logger.debug('RefinementService::loadFloorplan | floorplan id: %d, floorplan name: %s', srvFloorplan.id, srvFloorplan.name);
    const newCPFloorplan = new CPFloorplan(srvFloorplan, cpCapture);
    cpCapture.setCPFloorplan(newCPFloorplan);
    const isSuccess = await newCPFloorplan.downloadImage(this.cupixAuth.accessToken as string);
    if (!isSuccess) {
        logger.error('RefinementService::loadFloorplan | end - download image failed');
        this.jobManager.setErrorCode(ErrorCode.ScenemapperUtils.RefinerNotFoundFloorplan);
        throw new Error('download floorplan image failed');
    }
    // ...
};

로컬 경로 계산 로직은 file_extension 이 비어 있으면 meta.namepath.extname().slice(1) 로 대체하여 그대로 파일명에 붙인다. 슬래시/공백에 대한 sanitize 가 전혀 없어 값에 / 나 공백이 들어가면 상위 디렉터리 경로로 해석된다.

packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:35-55typescript
private setDefaultLocalPaths = (): boolean => {
    if (this.id == Constants.UnknownId) return false;
    if (this.cpCapture == undefined) return false;
    if (this.srvFloorplan == undefined) return false;

    const captureWorkspaceDirPath = this.cpCapture.workspaceDirPath;
    CPUtils.checkAndCreateFolder(captureWorkspaceDirPath);

    this._localMetaJsonFilePath = path.join(captureWorkspaceDirPath, `floorplan_${this.id}.json`);
    let originalImageFileExt = this.srvFloorplan.file_extension;
    if (originalImageFileExt == undefined || originalImageFileExt == '') {
        const originalImageFileName = (<any>this.srvFloorplan.meta)?.name;
        if (originalImageFileName != undefined) {
            originalImageFileExt = path.extname(originalImageFileName).slice(1);
        }
        logger.debug('CPFloorplan::setDefaultLocalPaths | floorplan file_extension: %s, originalImageFileExt: %s', this.srvFloorplan.file_extension, originalImageFileExt);
    }

    this._localImageFilePath = path.join(captureWorkspaceDirPath, `floorplan_${this.id}.${originalImageFileExt}`);
    return true;
};

downloadImage 는 write 실패를 catch 하되, 에러 객체를 JSON.stringify 해 warn 으로만 남기고 boolean false 만 반환한다. 상위 호출자는 저수준 원인(ENOENT, 경로) 을 볼 수 없다.

packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:57-75typescript
downloadImage = async (accessToken: string): Promise<boolean> => {
    if (this.srvFloorplan == undefined) return false;
    if (this.localImageFilePath == undefined) return false;

    try {
        const url = Environment.CPX_API_ENDPOINT + '/floorplans/' + this.id.toString() + '/download';
        const downloadOptions: any = {
            headers: { 'X-CUPIX-AUTH': accessToken },
            retry: 10
        };
        fs.writeFileSync(this.localImageFilePath, await download(url, undefined, downloadOptions));
    } catch (error) {
        logger.warn('CPFloorplan::downloadImage | %s', JSON.stringify(error));
        return false;
    }

    logger.debug('CPFloorplan::downloadImage | floorplanImageFilePath: %s, size: %d bytes', this.localImageFilePath, CPUtils.getFileSize(this.localImageFilePath));
    return true;
};

기대 동작 vs 실제 동작: 코드가 가정하는 값은 순수 파일 확장자(예: png, jpg, rvt) 이며 path.join 결과가 파일 경로여야 한다. 실제로 이번 floorplan 은 확장자 자리에 하위 경로(rvt-site/lvl 1) 가 들어와 path.join 이 이를 그대로 결합, floorplan_83272.rvt-site 이라는 존재하지 않는 디렉터리를 부모로 갖는 경로가 만들어져 open ENOENT 로 실패했다.

Log Evidence#

Datadog query (재현용):

text
service:cupixworks-capture-refinement-arm-instance @environment:production ("Floorplan" OR "floorplan" OR "loadFloorplan" OR "downloadImage")

Time range: 2026-07-15T02:26:00Z ~ 2026-07-15T02:30:00Z

핵심 로그 (시간 오름차순, KST):

text
2026-07-15 11:27:50 KST  info  RefinementService::init
2026-07-15 11:27:50 KST  info  RefinementService::authenticate | begin
2026-07-15 11:27:50 KST  info  RefinementService::authenticate | end
2026-07-15 11:27:50 KST  info  CupixAuth::setSession | session_id: be5f455c99b17a61261d4cdd4a847b40f60a505b
2026-07-15 11:27:50 KST  info  RefinementService::run | begin
2026-07-15 11:27:50 KST  info  JobManager::loadJob | begin - job id: 1197619
2026-07-15 11:27:50 KST  info  JobManager::loadJob | end - job id: 1197619
...
2026-07-15 11:28:46 KST  warn  CPFloorplan::downloadImage | {"errno":-2,"code":"ENOENT","syscall":"open","path":"/tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1"}
2026-07-15 11:28:46 KST  error RefinementService::loadFloorplan | end - download image failed
2026-07-15 11:28:46 KST  error download floorplan image failed

특기점:

  • warn 로그의 path 값이 결정적 증거다. /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1 에서 floorplan_83272.rvt-site 는 존재하지 않는 디렉터리 (captureWorkspaceDirPathcheckAndCreateFolder 로 생성되지만, 그 하위의 .rvt-site 는 만들지 않음).
  • errno:-2, code:"ENOENT", syscall:"open" 은 Node 의 fs.writeFileSync 가 존재하지 않는 상위 디렉터리에 대해 write 를 시도할 때 나오는 전형적 에러다. 다운로드 자체(download(url, ...)) 는 이미 완료된 후 write 단계에서 실패했다.
  • server 가 응답한 floorplan 83272 의 file_extension 또는 meta.name 값이 rvt-site/lvl 1 을 포함하는 문자열이라는 것을 시사한다 — Revit(.rvt) BIM floorplan 에서 site/level 을 이름에 포함하는 케이스로 보인다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 file_extension (또는 meta.name 파생 확장자) 에 슬래시/공백이 포함되어 path.join 이 존재하지 않는 하위 디렉터리를 만들며 fs.writeFileSync 가 ENOENT 로 실패 CPFloorplan::downloadImage warn 로그의 path: /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1, code:"ENOENT", syscall:"open" (2026-07-15 11:28:46 KST). 코드 cpfloorplan.ts:53 는 확장자를 sanitize 없이 파일명에 concat. 없음 Confirmed
H2 HTTP 다운로드 자체가 401/403/5xx 로 실패해 body 를 못 받아서 실패 download 라이브러리는 non-2xx 시 별도 exception 을 던지고 메시지에 상태코드가 포함되어야 하지만 실제 warn 로그의 에러 객체는 errno:-2, code:"ENOENT", syscall:"open" 로 순수 filesystem 에러 Confirmed 로그가 fs 계층 에러임을 확정 Rejected
H3 /tmp/workspace/731741 자체가 없어서 실패 (workspace 초기화 실패) 워크스페이스 상위 디렉터리가 없다면 다른 파일들(floorplan_${id}.json 등) 도 실패해야 함 ENOENT path 가 floorplan_83272.rvt-site/lvl 1 이므로 731741 은 존재. workspaceDirPath 는 checkAndCreateFolder 로 생성됨(cpfloorplan.ts:41) Rejected
H4 액세스 토큰 만료로 다운로드 실패 다운로드 실패라면 HTTP 계층 에러가 catch 되어 다른 메시지가 나옴 ENOENT 는 fs 계층 에러. 인증 실패의 흔적 없음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 파일: applications/agents/packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:35-55
  • 접근: originalImageFileExt 를 파일명에 붙이기 전에 반드시 sanitize 한다. 방향은 다음 중 하나 이상 조합:
    1. path.extname(name).slice(1) 결과에서 슬래시/역슬래시/공백/제어문자를 제거하거나 확장자로 판별되지 않으면 안전한 default(bin 또는 확장자 없음) 로 대체.
    2. 최종 파일명에 대해 path.basename 을 다시 적용하거나 [^a-zA-Z0-9._-] 를 하나의 안전 문자(_) 로 치환.
  • 근거: 확장자에 경로 구분자가 섞이는 것은 서버 데이터(파일 이름) 에 의존한 결과이므로, 클라이언트 쪽에서 파일 시스템에 안전한 이름으로 정규화하는 것이 재발 방지의 최소 조건이다.
  • 파일: applications/agents/packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:57-75
  • 접근: downloadImage catch 절에서 JSON.stringify(error) 대신 원본 Error 객체를 그대로 logger 에 넘겨 message/code/path 가 함께 남게 한다. 상위 loadFloorplan throw 문에도 원인 정보(예: floorplan id, 로컬 경로, ENOENT 여부) 를 포함시켜 재발 시 디버깅 시간을 단축한다.
  • 근거: 이번 사건도 warn 로그가 없었다면 원인 파악이 매우 어려웠다. logger convention (Error 객체 그대로 넘기기) 은 이미 사내 표준.

단기 개선 (1주 이내)#

  • 파일: applications/agents/packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:44-51
  • 서버 응답 file_extension 이 존재하는 경우와 meta.name 파생 케이스 모두에 대해, 예상되지 않는 확장자(빈 문자열, 슬래시 포함, 길이 과다) 를 감지하면 명시적으로 warn 로그를 남기고 정규화된 값으로 다운그레이드한다. floorplan record 자체의 데이터 이상은 tesla 쪽에서도 조사할 수 있도록 job 결과(job error code, structured payload) 에 원본 값과 정규화 결과를 포함시키는 것을 검토.
  • Server-side (tesla) 조사: floorplan 83272file_extension / meta.name 실제 값을 확인하고, Revit BIM floorplan 저장 시 site/level 이 이름 안에 들어가는 경로 컨벤션인지 확인해 서버 스키마 검증(슬래시 금지 등) 을 넣을지 결정.

장기 개선 (재발 방지)#

  • Agent 전반에서 외부 값(서버 응답, S3 key, 사용자 파일명) 을 로컬 파일 경로로 사용하는 지점을 감사해 sanitize 유틸(공통 safeFileName 등) 로 통일. 유사 코드가 cpbim.ts, cpcluster.ts 등에도 반복될 가능성 큼.
  • Refinement/agent 계열 서비스에서 파일 시스템 관련 exception 은 error 레벨에서 원본 Error(code/path 포함) 를 그대로 로깅하도록 lint 규칙 또는 code review checklist 화.

Monitoring#

  • 추가 메트릭/알림 방향:
    • Refinement job 실패 카운트 (error 레벨 로그 기반) — floorplan 관련 실패를 별도 서브 카운트로 분리.
    • ENOENT 를 포함한 filesystem 계열 에러가 다시 나오는지 감지.

Datadog 쿼리 예시 (release dashboard timeseries widget 에 그대로 넣을 수 있는 표현):

text
service:cupixworks-capture-refinement-arm-instance status:error "download floorplan image failed"
text
service:cupixworks-capture-refinement-arm-instance "CPFloorplan::downloadImage" "ENOENT"
text
service:cupixworks-capture-refinement-arm-instance status:error "RefinementService::loadFloorplan"

Risk Assessment#

  • Risk level: medium — blast radius 는 개별 job 이지만, 동일 floorplan record 를 참조하는 후속 job 은 계속 실패한다. 다른 BIM/Revit floorplan 이 같은 이름 패턴이면 반복 재현 가능.
  • 예상 복잡도: standard — sanitize 함수 추가와 로거 개선은 단일 파일 수준. 서버 데이터 정규화(스키마) 를 함께 진행하면 조율 필요.