`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
105 lines
3.8 KiB
TypeScript
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'};
|
|
}
|