fix(site-analysis): per-call AbortController, не shared cancelledRef (#1242)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m5s
CI / frontend-tests (push) Successful in 1m6s
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m5s
CI / frontend-tests (push) Successful in 1m6s
Конкурентные analyze делили один cancelledRef: новая мутация ставила ref=false и воскрешала спящий poll-цикл предыдущей — лишние fetch-status/ POST analyze и setFetchingState(null) затирали баннер активного запроса. Patch: shared boolean ref → per-call AbortController, signal проброшен в apiFetch/apiFetchWithStatus, добавлен abortableSleep чтобы цикл просыпался мгновенно на cancel. setFetchingState(null) guarded signal.aborted — зомби-цикл не может перезаписать состояние актуальной мутации. 82/82 frontend тестов зелёные. Closes #1242
This commit is contained in:
parent
06c4bb9fd6
commit
9fbc28fa33
1 changed files with 62 additions and 11 deletions
|
|
@ -51,6 +51,31 @@ export interface AnalyzeOptions {
|
||||||
weights?: Record<string, number> | null;
|
weights?: Record<string, number> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep, but resolve early if the AbortSignal fires.
|
||||||
|
*
|
||||||
|
* Используем чтобы цикл polling'а пробуждался сразу при cancel(),
|
||||||
|
* а не ждал полные POLL_INTERVAL_MS до следующей проверки.
|
||||||
|
*/
|
||||||
|
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
||||||
*
|
*
|
||||||
|
|
@ -60,6 +85,12 @@ export interface AnalyzeOptions {
|
||||||
* - fetching: получили 202, идёт polling /fetch-status
|
* - fetching: получили 202, идёт polling /fetch-status
|
||||||
* - data set: ParcelAnalysis отрисовываем
|
* - data set: ParcelAnalysis отрисовываем
|
||||||
* - error: not_in_nspd / invalid_format / failed
|
* - error: not_in_nspd / invalid_format / failed
|
||||||
|
*
|
||||||
|
* Отмена (issue #1242): per-call AbortController. cancel() абортит ТОЛЬКО
|
||||||
|
* текущий цикл — новая мутация создаёт свой controller, поэтому
|
||||||
|
* "воскресший" poll-цикл предыдущего вызова не может затронуть состояние
|
||||||
|
* актуального. setFetchingState вызывается только если signal не aborted —
|
||||||
|
* иначе баннер ETA/cancel активного запроса был бы перезаписан зомби-циклом.
|
||||||
*/
|
*/
|
||||||
export function useSiteAnalysis() {
|
export function useSiteAnalysis() {
|
||||||
const [fetchingState, setFetchingState] = useState<{
|
const [fetchingState, setFetchingState] = useState<{
|
||||||
|
|
@ -67,8 +98,9 @@ export function useSiteAnalysis() {
|
||||||
jobId: number;
|
jobId: number;
|
||||||
etaSeconds: number;
|
etaSeconds: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
// ref для cancel polling — invalidates in-flight iteration
|
// Per-call AbortController. cancel() абортит только текущий цикл;
|
||||||
const cancelledRef = useRef(false);
|
// конкурентные мутации не делят токен отмены (фикс #1242).
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
|
|
@ -78,7 +110,13 @@ export function useSiteAnalysis() {
|
||||||
cad: string;
|
cad: string;
|
||||||
options?: AnalyzeOptions;
|
options?: AnalyzeOptions;
|
||||||
}): Promise<ParcelAnalysis> => {
|
}): Promise<ParcelAnalysis> => {
|
||||||
cancelledRef.current = false;
|
// Абортим предыдущий in-flight цикл (если ещё спит / поллит)
|
||||||
|
// и создаём новый controller для текущего вызова.
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
const { signal } = controller;
|
||||||
|
|
||||||
setFetchingState(null);
|
setFetchingState(null);
|
||||||
|
|
||||||
// Build optional query string for weight profile params.
|
// Build optional query string for weight profile params.
|
||||||
|
|
@ -106,6 +144,7 @@ export function useSiteAnalysis() {
|
||||||
ParcelAnalysis | AnalyzeAcceptedResponse
|
ParcelAnalysis | AnalyzeAcceptedResponse
|
||||||
>(analyzeUrl(cad), {
|
>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
signal,
|
||||||
...(bodyPayload
|
...(bodyPayload
|
||||||
? {
|
? {
|
||||||
body: bodyPayload,
|
body: bodyPayload,
|
||||||
|
|
@ -121,6 +160,11 @@ export function useSiteAnalysis() {
|
||||||
// 202 Accepted — entering polling mode
|
// 202 Accepted — entering polling mode
|
||||||
if (first.status === 202) {
|
if (first.status === 202) {
|
||||||
const accepted = first.body as AnalyzeAcceptedResponse;
|
const accepted = first.body as AnalyzeAcceptedResponse;
|
||||||
|
// Guard: не перезаписываем баннер если этот вызов уже отменили
|
||||||
|
// (succeeded race: cancel пришёл во время первого POST).
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new Error("Загрузка отменена пользователем");
|
||||||
|
}
|
||||||
setFetchingState({
|
setFetchingState({
|
||||||
cadNum: cad,
|
cadNum: cad,
|
||||||
jobId: accepted.job_id,
|
jobId: accepted.job_id,
|
||||||
|
|
@ -129,12 +173,13 @@ export function useSiteAnalysis() {
|
||||||
|
|
||||||
// Poll loop
|
// Poll loop
|
||||||
for (let i = 0; i < POLL_MAX_ITERATIONS; i++) {
|
for (let i = 0; i < POLL_MAX_ITERATIONS; i++) {
|
||||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
await abortableSleep(POLL_INTERVAL_MS, signal);
|
||||||
if (cancelledRef.current) {
|
if (signal.aborted) {
|
||||||
throw new Error("Загрузка отменена пользователем");
|
throw new Error("Загрузка отменена пользователем");
|
||||||
}
|
}
|
||||||
const status = await apiFetch<FetchStatusResponse>(
|
const status = await apiFetch<FetchStatusResponse>(
|
||||||
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
|
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
|
||||||
|
{ signal },
|
||||||
);
|
);
|
||||||
if (status.status === "ready") {
|
if (status.status === "ready") {
|
||||||
// Re-trigger analyze — should be 200 now.
|
// Re-trigger analyze — should be 200 now.
|
||||||
|
|
@ -144,6 +189,7 @@ export function useSiteAnalysis() {
|
||||||
// mutation сразу резолвится с data — render skipped.
|
// mutation сразу резолвится с data — render skipped.
|
||||||
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
signal,
|
||||||
...(bodyPayload
|
...(bodyPayload
|
||||||
? {
|
? {
|
||||||
body: bodyPayload,
|
body: bodyPayload,
|
||||||
|
|
@ -151,11 +197,15 @@ export function useSiteAnalysis() {
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
setFetchingState(null);
|
// Только если этот вызов всё ещё актуален — иначе зомби-цикл
|
||||||
|
// стирает баннер свежей мутации (#1242).
|
||||||
|
if (!signal.aborted) {
|
||||||
|
setFetchingState(null);
|
||||||
|
}
|
||||||
return second;
|
return second;
|
||||||
}
|
}
|
||||||
if (status.status === "not_in_nspd") {
|
if (status.status === "not_in_nspd") {
|
||||||
setFetchingState(null);
|
if (!signal.aborted) setFetchingState(null);
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
404,
|
404,
|
||||||
status,
|
status,
|
||||||
|
|
@ -163,7 +213,7 @@ export function useSiteAnalysis() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status.status === "failed") {
|
if (status.status === "failed") {
|
||||||
setFetchingState(null);
|
if (!signal.aborted) setFetchingState(null);
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
503,
|
503,
|
||||||
status,
|
status,
|
||||||
|
|
@ -171,7 +221,7 @@ export function useSiteAnalysis() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status.status === "invalid_format") {
|
if (status.status === "invalid_format") {
|
||||||
setFetchingState(null);
|
if (!signal.aborted) setFetchingState(null);
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
400,
|
400,
|
||||||
status,
|
status,
|
||||||
|
|
@ -181,7 +231,7 @@ export function useSiteAnalysis() {
|
||||||
// status === "fetching" → continue polling
|
// status === "fetching" → continue polling
|
||||||
}
|
}
|
||||||
// Polling exhausted (2 min)
|
// Polling exhausted (2 min)
|
||||||
setFetchingState(null);
|
if (!signal.aborted) setFetchingState(null);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
|
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
|
||||||
);
|
);
|
||||||
|
|
@ -192,7 +242,8 @@ export function useSiteAnalysis() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
cancelledRef.current = true;
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
setFetchingState(null);
|
setFetchingState(null);
|
||||||
mutation.reset();
|
mutation.reset();
|
||||||
}, [mutation]);
|
}, [mutation]);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue