o2o-site-AEO/solution/frontend/src/api/pollJob.ts
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

105 lines
3.8 KiB
TypeScript

/**
* 잡 폴링 단일 구현.
*
* 수집·비전·생성·빌드는 전부 같은 모양이다 — `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<void>((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<JobOutcome> {
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'};
}