/** * 잡 폴링 단일 구현. * * 수집·비전·생성·빌드는 전부 같은 모양이다 — `POST` 로 job_id 를 받고 * `GET /v1/job/{job_id}` 를 DONE/DEAD 까지 물어본다(백엔드 README 의 "잡을 적재하고 즉시 응답"). * 그래서 화면마다 while 루프를 다시 쓰지 않고 여기 한 곳만 둔다. * * ★ 폴링에는 React Query 훅을 쓰지 않는다. 훅은 렌더 트리에 묶여 있어서 * "버튼을 누른 뒤 끝날 때까지"라는 명령형 흐름(진행 문구·단계 전환)을 표현하지 못한다. * 대신 orval 이 만든 같은 `getJob` 함수를 쓴다 — 토큰·baseURL·401 재시도는 mutator 가 처리한다. */ import {JobStatus} from '@o2o/shared'; import {getJob} from './generated/job/job'; import type {JobData} from './generated/model'; /** 잡 폴링 간격. 수집·빌드는 분 단위로 도는 작업이라 더 촘촘히 물어봐도 얻는 게 없다. */ export const JOB_POLL_MS = 2000; /** ★ 폴링 총 제한시간. 워커가 죽어 잡이 RUNNING 에 박제되면 여기서 끊는다 — 무한 폴링 금지. */ export const JOB_POLL_TIMEOUT_MS = 3 * 60 * 1000; /** ★ 연속 실패 허용치. 잠깐 끊긴 건 넘기고, 계속 실패하면 제한시간을 다 기다리지 않고 접는다. */ export const JOB_MAX_POLL_ERRORS = 3; /** * 폴링이 끝난 이유. * * ★ `done` 이 "성공"은 아니다. 잡은 끝났다는 뜻일 뿐이고, 게이트 거부처럼 도메인 판정은 * `job.result` 안에 있다(빌드는 build_status·gate). 호출측이 그걸 읽어야 한다. */ export type JobOutcome = | {kind: 'done'; job: JobData} | {kind: 'dead'; job: JobData} /** 제한시간 초과. 잡은 서버에서 계속 돈다 — 화면만 풀어 준다. */ | {kind: 'timeout'} /** 언마운트·재시작으로 끊겼다. 조용히 끝낸다(토스트 금지). */ | {kind: 'aborted'} /** 연속 실패로 상태를 못 읽었다. 잡의 성패는 알 수 없다. */ | {kind: 'unreachable'; error: unknown}; export interface PollJobOptions { signal: AbortSignal; intervalMs?: number; timeoutMs?: number; maxErrors?: number; /** 폴링 1회마다 호출. 진행 문구·단계 표시를 여기서 갱신한다. */ onTick?: (job: JobData) => void; } /** 폴링 간격 대기. abort 되면 즉시 깨어난다 — 죽은 타이머를 남기지 않는다. */ export function delay(ms: number, signal: AbortSignal) { return new Promise((resolve) => { if (signal.aborted) return resolve(); const timer = setTimeout(resolve, ms); signal.addEventListener( 'abort', () => { clearTimeout(timer); resolve(); }, {once: true}, ); }); } export async function pollJob(jobId: string, options: PollJobOptions): Promise { const { signal, intervalMs = JOB_POLL_MS, timeoutMs = JOB_POLL_TIMEOUT_MS, maxErrors = JOB_MAX_POLL_ERRORS, onTick, } = options; const deadline = Date.now() + timeoutMs; let errors = 0; let lastError: unknown; while (Date.now() < deadline) { await delay(intervalMs, signal); if (signal.aborted) return {kind: 'aborted'}; let job: JobData | undefined; try { const polled = await getJob(jobId, undefined, signal); job = polled.job ?? undefined; } catch (error) { if (signal.aborted) return {kind: 'aborted'}; lastError = error; } // 한 번이라도 잡을 받아오면 카운터를 되돌린다 — 잠깐 끊긴 네트워크로 몇 분짜리 잡을 접으면 안 된다. errors = job ? 0 : errors + 1; if (errors >= maxErrors) return {kind: 'unreachable', error: lastError}; if (!job) continue; onTick?.(job); if (job.status === JobStatus.DONE) return {kind: 'done', job}; if (job.status === JobStatus.DEAD) return {kind: 'dead', job}; } return {kind: 'timeout'}; }