Saga в состоянии failed_compensation
Сага исчерпала retry-бюджет компенсации (5 попыток) и застыла в
статусе failed_compensation. Это значит: исходная операция
откатывается, но один из compensating-шагов падает и не может
довести откат до конца. Данные в системе сейчас в рассогласованном
состоянии — есть residual-эффекты (reservation, запись,
нотификация), которые нужно снять вручную.
Симптомы:
- Alert
SagaFailedCompensation(см.../conventions/slo-and-budget) — page. - Gauge
saga_stuck_total{type=...} > 0. - В
saga_instancesWHEREstatus='failed_compensation'появилась запись, которой час назад не было.
Reference по паттерну — ../patterns/saga.
Reference по конкретной схеме шагов — в коде сервиса-оркестратора,
internal/saga/<type>/.
Содержание
- Не паниковать
- Найти застрявшую сагу
- Разобрать состояние шагов
- Решение: закрыть вручную
- Решение: повторить компенсацию после фикса
- Финализация записи
- Post-incident
- Что не делать
- Связанные разделы
Не паниковать
failed_compensation не означает, что данные уничтожены. Это
означает: система не смогла довести откат до конца автоматически
и корректно зафиксировала этот факт, чтобы инженер разобрал
руками. Автоматический retry на шестой раз не поможет — если пять
retry подряд упали, упадёт и шестой.
Первый шаг — не трогать. Второй — прочитать state саги.
Найти застрявшую сагу
SELECT
id,
saga_type,
status,
current_step,
state,
updated_at,
NOW() - updated_at AS stuck_for
FROM saga_instances
WHERE status = 'failed_compensation'
ORDER BY updated_at DESC;Нужно зафиксировать:
id— идентификатор для correlation и дальнейших запросов.saga_type— определяет, кто owner саги (см.ActiveSagas-реестр вinternal/saga/registry.goсервиса).current_step— шаг, на котором застряла компенсация.state— JSONB сmedia_ids,review_id,notification_id,last_error. Ключ для восстановления эффекта.updated_at— как давно упала последняя попытка компенсации.
Для горизонтального контекста — соседние записи в outbox и logs по
saga_id:
SELECT aggregate_id, event_type, created_at, offset_acked
FROM outbox
WHERE metadata->>'saga_id' = '<uuid>'
ORDER BY created_at DESC
LIMIT 20;Логи по saga_id в Loki (см.
../how-to/read-logs):
{service=~".+"} |= "<saga_id>" | json | level=~"WARN|ERROR"Трейсы по тому же saga_id в Tempo — если saga_id прокидывается
через trace.WithAttributes, фильтр по attribute.
Разобрать состояние шагов
Для каждого успешно завершённого forward-шага есть побочный эффект в downstream-сервисе. Нужно проверить, в каком состоянии он сейчас, чтобы понять, докуда компенсация уже успела дойти.
Шаблон — для publish_review.v1 (media → review → notification):
-- media-service: reservation'ы этой саги
SELECT saga_id, step_idx, media_id, status, created_at, released_at
FROM media.reservations
WHERE saga_id = '<uuid>';
-- review-service: локальная запись ревью
SELECT id, status, created_at, deleted_at, rollback_reason
FROM reviews
WHERE id = (SELECT (state->>'review_id')::bigint
FROM saga_instances WHERE id = '<uuid>');
-- notification-service: enqueued запись
SELECT id, status, created_at, cancelled_at
FROM notifications
WHERE saga_id = '<uuid>';Вывод:
- Какие шаги forward успешно прошли (есть downstream-запись).
- Какие шаги компенсация уже сняла (
released_at,deleted_at,cancelled_atНЕ NULL). - Какой шаг застрял (downstream-запись есть, rollback-пометки
нет,
saga_instances.state.last_errorуказывает на этот сервис).
Последний last_error + имя шага + сервис-owner — определяют, куда
идти дальше.
Решение: закрыть вручную
Этот путь — когда разобрался, что downstream-эффект либо уже снят, либо безопасно снимать прямо сейчас одной ручной командой.
Пример: media.Release падал с constraint violation, потому что
reservation уже удалён предыдущей ручной операцией неделю назад. В
downstream всё чисто — нужно просто сагу закрыть.
Порядок:
-
Snapshot текущего
stateсаги вstate->>'manual_resolution'перед любой правкой — чтобы post-mortem имел полный контекст:UPDATE saga_instances SET state = state || jsonb_build_object( 'manual_resolution', jsonb_build_object( 'by', 'islam@kazmaps', 'at', NOW(), 'reason', 'reservation already released out-of-band', 'prev_state', state ) ) WHERE id = '<uuid>'; -
Перевести сагу в терминальный статус —
failed, потому что исходная операция не завершилась (даже если downstream чист):UPDATE saga_instances SET status = 'failed', completed_at = NOW(), updated_at = NOW() WHERE id = '<uuid>' AND status = 'failed_compensation'; -- guardcompleted— только если ты доказал, что исходная операция на самом деле прошла и откат был ложной тревогой (крайне редкий случай). По умолчанию —failed. -
Инкремент
saga_manual_resolution_total{type, reason}— счётчик ручных разборов. Не требуется technically, но помогает потом увидеть паттерн: «тот же тип падает третий раз за месяц — проблема в коде компенсации, а не в данных».
Решение: повторить компенсацию после фикса
Этот путь — когда downstream сам не справляется, но
восстанавливается после фикса в коде или конфиге. Пример:
notification.Cancel падал из-за неправильной роли в БД, роль
исправлена.
Порядок:
-
Сначала фикс в downstream (выкатить патч / изменить конфиг / восстановить данные). Проверить на одном саге, что компенсация пройдёт — через ручной
emitCompensationв staging, если он доступен. В prod — не «давай на боевой попробуем». -
Сбросить счётчик попыток компенсации, оставить статус в
compensating:UPDATE saga_instances SET status = 'compensating', state = jsonb_set(state, '{compensation_attempts}', 'null'), updated_at = NOW() WHERE id = '<uuid>' AND status = 'failed_compensation'; -
Принудительно отправить команду компенсации в outbox:
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, metadata, created_at) VALUES ( gen_random_uuid(), 'saga', '<uuid>', '<service>.<step>.compensate', '<payload JSON>'::jsonb, jsonb_build_object('saga_id', '<uuid>', 'step_idx', <n>, 'manual_replay', true), NOW() );Forwarder подхватит и отправит. Consumer снова выполнит компенсацию — теперь она пройдёт, сага догонит до
failedавтоматически. -
Мониторить в течение 10–15 минут:
SELECT status, updated_at FROM saga_instances WHERE id = '<uuid>';Статус должен смениться с
compensatingнаfailedза несколько минут. Если сноваfailed_compensation— значит фикс был неполный; повтори диагностику, не продолжай замыкать в ручной replay без разбора.
Финализация записи
После любого из двух путей:
- Commit в
state->>'manual_resolution'с ФИО, датой, причиной, списком ручных действий. - Тикет / issue на постоянный фикс в коде, если причина — баг
компенсации, а не data-anomaly one-off.
failed_compensation, который повторяется на одной и той же операции, — не «случайность», а лакуна в компенсации. - Комментарий в post-mortem threads (см. post-incident).
Запись в saga_instances не удаляй руками — это audit trail.
Retention по нему определяется
../conventions/data-retention
на уровне сервиса.
Post-incident
После закрытия саги:
- Провести разбор в ближайший рабочий день (не «через неделю»).
- Ответить на вопросы:
- Почему компенсация упала 5 раз подряд? (transient error, structural bug, bad data?)
- Почему автоматический retry не справился? Нужен ли более агрессивный backoff, более длинный retry-budget?
- Есть ли у компенсации идемпотентность? (если нет — это и
причина; см.
../patterns/saga) - Что должно было сработать в мониторинге раньше? (freshness SLO, lag alert)
- Если одна и та же сага падает повторно — рассматривай это как
долговой тикет, не как «редкий инцидент». 3 ×
failed_compensationодногоsaga_typeза квартал = переработать шаг.
Что не делать
- Не удалять запись
saga_instancesвручную. Это audit trail; удаление прячет факт инцидента и ломает последующие разборы. - Не перезапускать сагу с нуля, если downstream-эффекты
частично сделаны. Получишь дубли (
media.Reserveна уже reserved media, второйreviewв БД). - Не править
statusвcompensatingбез фикса в downstream. Автоматический replay снова упадёт, счётчик ручных попыток засорится. - Не звать API downstream-сервисов напрямую руками («sql- ковыряния через psql в prod»), если есть path через outbox + команду. Outbox даёт audit и at-least-once; ручной psql в prod не фиксируется.
- Не откладывать разбор. В состоянии
failed_compensationданные рассогласованы; каждый час — потенциальный побочный эффект (пользователь видит ревью, которое должно было откатиться). - Не считать
failed_compensation«штатным». Любое срабатывание — инцидент, даже если закрылся за 15 минут. Иначе alert перестаёт работать.
Связанные разделы
../patterns/saga— паттерн в деталях, versioning шагов, идемпотентность компенсаций.../patterns/outbox— почему команды шагов идут через outbox, а не через прямой HTTP.../conventions/slo-and-budget— SLO и alert’ы для saga orchestrator.../how-to/debug-outbox-lag— если сага «не движется» из-за forwarder’а, а не из-за компенсации.../how-to/read-logs— LogQL-запросы поsaga_id.../how-to/read-traces— trace-view саги в Tempo.