Перейти к основному содержимому

Async-саги

Часть write-операций MineFlow исполняется не синхронно, а как сага — цепочка шагов с компенсацией при сбое. Такой POST не возвращает результат сразу: бэк ставит сагу в очередь, отвечает 202 с идентификатором саги и продолжает работу в фоне. Фронт опрашивает статус до терминального состояния (SSE для саг нет).

Канонический пример — утверждение сменного рапорта: центральная 5-шаговая approve-сага. Сторнирование уже утверждённого рапорта — компенсирующая сага.

Сначала — React

В React почти всё за тебя делают useApproveShiftReport / useRejectShiftReportAfterApprove (и обобщённый useSagaMutation). Прямой pollSaga из ядра нужен в не-React коде или для нестандартных сценариев. Раздел про ядро ниже объясняет, что именно происходит под капотом.

TL;DR​

import { useApproveShiftReport } from '@mineflow/client-react';

function ApproveButton({ id }: { id: string }) {
const approve = useApproveShiftReport();

return (
<button
disabled={approve.isPending}
onClick={() =>
approve.mutate(
{ id },
{
onSuccess: () => toast('Рапорт утверждён'),
onError: () => toast('Сага откатилась — рапорт не утверждён'),
},
)
}
>
{approve.isPending ? 'Утверждаю…' : 'Утвердить'}
</button>
);
}

approve.data — это финальный SagaStatus (вся хронология шагов). Терминальный провал саги уходит в onError, а не в onSuccess — см. ниже.

Форма ответа: 202 + sagaId​

POST саги отдаёт не доменную сущность, а идентификатор саги. Важно: поле называется по-разному в зависимости от операции — sagaId для approve, rejectSagaId для сторно. Хуки уже знают, какое поле читать.

// approve
const res = unwrap(await c.POST('/api/v1/prd/shift-reports/{id}/approve', { /* ... */ }));
res.sagaId; // → строка

// reject-after-approve (компенсация)
const res = unwrap(await c.POST('/api/v1/prd/shift-reports/{id}/reject-after-approve', { /* ... */ }));
res.rejectSagaId; // → строка

Полный список саг и их полей — в REST-референсе (роуты с ответом 202).

Поллинг статуса: pollSaga​

Ядро экспортирует pollSaga — он опрашивает GET /api/v1/sagas/{sagaId}/status, пока сага не «устаканится», и резолвится финальным SagaStatus.

import { pollSaga, unwrap } from '@mineflow/client-core';

const { sagaId } = unwrap(
await client.POST('/api/v1/prd/shift-reports/{id}/approve', {
params: { path: { id }, header: { 'Idempotency-Key': '' } },
}),
);

const final = await pollSaga(client, sagaId, {
intervalMs: 1000, // период опроса (по умолчанию 1000)
timeoutMs: 60_000, // лимит ожидания (по умолчанию 60_000)
});
// final.steps — полная хронология шагов
warning
pollSaga НЕ бросает на провале саги

pollSaga бросает только SagaTimeoutError (см. ниже). Если сага закончилась откатом (failed/compensated), он резолвится этим статусом, а не reject'ит промис. Решение «успех или провал» принимаешь ты — в React это делает хук автоматически. Подробнее — в разделе про терминальный провал.

Опции PollSagaOptions​

ОпцияПо умолчаниюНазначение
intervalMs1000Пауза между опросами.
timeoutMs60_000Общий лимит; по превышении — SagaTimeoutError.
isSettled—Переопределяемый предикат «сага завершена» (по умолчанию defaultIsSettled).
sleep—Своя реализация задержки (полезно в тестах).
now—Свой источник времени (полезно в тестах).

Полные сигнатуры и типы — в API-референсе client-core.

Почему статус считается по последнему шагу​

saga_log на бэке — append-only event-log: каждая смена статуса шага пишется отдельной строкой, и endpoint отдаёт их все (упорядоченные по времени, без дедупа). Значит для успешного шага в ответе одновременно присутствуют строки running и completed. Поэтому наивное «все строки = completed» не работает — статус саги вычисляется по последнему статусу каждого шага.

Канонический словарь статусов шага (источник истины — бэк, протекает в SDK через OpenAPI):

'pending' | 'running' | 'completed' | 'failed' | 'compensated'

Ядро даёт три чистые функции поверх этой логики:

import {
latestStatusByStep,
defaultIsSettled,
hasTerminalFailure,
} from '@mineflow/client-core';

// Map<stepName, последний_статус> — сворачивает append-only строки
const latest = latestStatusByStep(final); // Map { 'step-a' => 'completed', ... }

// Есть ли шаг в терминальном провале (failed | compensated)?
hasTerminalFailure(final); // boolean

// Завершена ли сага: любой шаг failed/compensated → true; иначе все completed → true
defaultIsSettled(final); // boolean
Провал терминален сразу, успех — стабилизируется

failed/compensated окончателен независимо от будущих шагов — pollSaga возвращает его немедленно. Успех же дополнительно «стабилизируется» вторым опросом против растущего списка шагов: иначе можно вернуться на «все текущие шаги completed», когда саге ещё предстоят шаги. Эту логику закрывает defaultIsSettled — обычно переопределять isSettled не нужно.

Таймаут: SagaTimeoutError​

Если сага не уложилась в timeoutMs, pollSaga бросает SagaTimeoutError с полями sagaId и timeoutMs:

import { pollSaga, SagaTimeoutError } from '@mineflow/client-core';

try {
await pollSaga(client, sagaId, { timeoutMs: 30_000 });
} catch (e) {
if (e instanceof SagaTimeoutError) {
// сага не завершилась за отведённое время — НЕ значит, что она провалилась.
// Покажи «обработка занимает дольше обычного» и/или дай повторить опрос.
console.warn(e.sagaId, e.timeoutMs);
}
}
Таймаут ≠ провал

SagaTimeoutError означает лишь, что фронт перестал ждать — сага на бэке может ещё идти и успешно завершиться. Не показывай пользователю «операция не удалась» по таймауту; предложи проверить статус позже.

React: useSagaMutation и доменные хуки​

useSagaMutation оборачивает «POST → pollSaga → проверка результата» в обычную TanStack-мутацию. Ему передаётся функция start, которая делает POST и возвращает { sagaId }:

import { useSagaMutation } from '@mineflow/client-react';
import { unwrap } from '@mineflow/client-core';

function useApproveShiftReportLike() {
return useSagaMutation(async (client, vars: { id: string }) => {
const res = unwrap(
await client.POST('/api/v1/prd/shift-reports/{id}/approve', {
params: { path: { id: vars.id }, header: { 'Idempotency-Key': '' } },
}),
);
return { sagaId: res.sagaId };
});
}

Готовые доменные хуки уже сделаны на нём:

import {
useApproveShiftReport,
useRejectShiftReportAfterApprove,
} from '@mineflow/client-react';
  • useApproveShiftReport() — утверждение рапорта ({ id }). POST → 202 + sagaId → поллинг.
  • useRejectShiftReportAfterApprove() — сторнирование утверждённого рапорта ({ id, body }, body.reason ≥ 10 символов, только CEO). POST → 202 + rejectSagaId → поллинг компенсирующей саги.

Оба возвращают UseMutationResult<SagaStatus, …>: mutation.data после успеха — финальный SagaStatus.

function RejectApprovedButton({ id }: { id: string }) {
const reject = useRejectShiftReportAfterApprove();

return (
<button
disabled={reject.isPending}
onClick={() =>
reject.mutate({ id, body: { reason: 'Ошибочно утверждён диспетчером' } })
}
>
Сторнировать утверждение
</button>
);
}
Idempotency-Key проставляется сам

В примерах header: { 'Idempotency-Key': '' } — это плейсхолдер: реальный ключ ставит аутентифицированный fetch автоматически на все write. Руками его генерировать не нужно. Подробнее — гайд client-core.

Терминальный провал → onError​

Ключевой момент: pollSaga резолвится терминально-отказным статусом, а не бросает. Если бы хук просто отдал этот статус в onSuccess, UI показал бы «утверждено» поверх рапорта, который на деле остался неутверждённым (форвард-операция откатилась).

Поэтому useSagaMutation после поллинга вызывает assertSagaSucceeded, который превращает терминальный провал в брошенную SagaFailedError — и у потребителя срабатывает onError, а не onSuccess:

import { SagaFailedError } from '@mineflow/client-react';

approve.mutate(
{ id },
{
onSuccess: (status) => {
// сюда попадаем ТОЛЬКО при реальном успехе (все шаги completed)
toast.success('Рапорт утверждён');
},
onError: (err) => {
if (err instanceof SagaFailedError) {
// сага откатилась: err.status — финальный SagaStatus с шагами failed/compensated
toast.error('Утверждение не выполнено — изменения откачены');
} else {
// прочее: SagaTimeoutError, сетевые/доменные ошибки на старте (MineflowApiError)
toast.error('Не удалось запустить утверждение');
}
},
},
);

SagaFailedError несёт поле status: SagaStatus — финальную хронологию, по которой можно показать, какой именно шаг упал.

Если нужна та же проверка вне React (поверх ручного pollSaga), используй те же символы из client-react:

import { assertSagaSucceeded, SagaFailedError } from '@mineflow/client-react';
import { pollSaga, SagaTimeoutError } from '@mineflow/client-core';

try {
const final = assertSagaSucceeded(await pollSaga(client, sagaId));
// final — гарантированно успешная сага
} catch (e) {
if (e instanceof SagaFailedError) {
/* откат */
} else if (e instanceof SagaTimeoutError) {
/* истёк лимит ожидания */
}
}
Не считай провал успехом

Никогда не строй UI на «POST вернул 202 → значит готово». 202 — это лишь «принято в обработку». Финал саги может быть откатом; ориентируйся на onSuccess/onError хука (или assertSagaSucceeded вручную).

Разбор финального статуса​

Если нужно показать пользователю детали шагов (например, на каком шаге сорвалось), разбери SagaStatus теми же ядровыми функциями:

import { latestStatusByStep, hasTerminalFailure } from '@mineflow/client-core';

function SagaSummary({ status }: { status: SagaStatus }) {
const steps = [...latestStatusByStep(status)]; // [stepName, status][]
const failed = hasTerminalFailure(status);

return (
<ul>
{steps.map(([name, st]) => (
<li key={name}>
{name}: {st} {st === 'failed' || st === 'compensated' ? '⚠️' : '✅'}
</li>
))}
</ul>
);
}

Связанное​

  • Обработка ошибок — MineflowApiError, RFC 7807, гейтинг UI по error.code (ошибки на старте POST до запуска саги).
  • client-core — pollSaga, defaultIsSettled, SagaTimeoutError и остальное ядро.
  • client-react — useSagaMutation, доменные саговые хуки, провайдер.
  • RBAC — кто может утверждать/сторнировать (роли проверяет сервер).
  • API-референс: client-core, client-react. REST: /rest/.