Skip to Content
TroubleshootingSaga: failed compensation

Saga в состоянии failed_compensation

Сага исчерпала retry-бюджет компенсации (5 попыток) и застыла в статусе failed_compensation. Это значит: исходная операция откатывается, но один из compensating-шагов падает и не может довести откат до конца. Данные в системе сейчас в рассогласованном состоянии — есть residual-эффекты (reservation, запись, нотификация), которые нужно снять вручную.

Симптомы:

  • Alert SagaFailedCompensation (см. ../conventions/slo-and-budget) — page.
  • Gauge saga_stuck_total{type=...} > 0.
  • В saga_instances WHERE status='failed_compensation' появилась запись, которой час назад не было.

Reference по паттерну — ../patterns/saga. Reference по конкретной схеме шагов — в коде сервиса-оркестратора, internal/saga/<type>/.

Содержание

Не паниковать

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 всё чисто — нужно просто сагу закрыть.

Порядок:

  1. 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>';
  2. Перевести сагу в терминальный статус — failed, потому что исходная операция не завершилась (даже если downstream чист):

    UPDATE saga_instances SET status = 'failed', completed_at = NOW(), updated_at = NOW() WHERE id = '<uuid>' AND status = 'failed_compensation'; -- guard

    completed — только если ты доказал, что исходная операция на самом деле прошла и откат был ложной тревогой (крайне редкий случай). По умолчанию — failed.

  3. Инкремент saga_manual_resolution_total{type, reason} — счётчик ручных разборов. Не требуется technically, но помогает потом увидеть паттерн: «тот же тип падает третий раз за месяц — проблема в коде компенсации, а не в данных».

Решение: повторить компенсацию после фикса

Этот путь — когда downstream сам не справляется, но восстанавливается после фикса в коде или конфиге. Пример: notification.Cancel падал из-за неправильной роли в БД, роль исправлена.

Порядок:

  1. Сначала фикс в downstream (выкатить патч / изменить конфиг / восстановить данные). Проверить на одном саге, что компенсация пройдёт — через ручной emitCompensation в staging, если он доступен. В prod — не «давай на боевой попробуем».

  2. Сбросить счётчик попыток компенсации, оставить статус в compensating:

    UPDATE saga_instances SET status = 'compensating', state = jsonb_set(state, '{compensation_attempts}', 'null'), updated_at = NOW() WHERE id = '<uuid>' AND status = 'failed_compensation';
  3. Принудительно отправить команду компенсации в 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 автоматически.

  4. Мониторить в течение 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

После закрытия саги:

  • Провести разбор в ближайший рабочий день (не «через неделю»).
  • Ответить на вопросы:
    1. Почему компенсация упала 5 раз подряд? (transient error, structural bug, bad data?)
    2. Почему автоматический retry не справился? Нужен ли более агрессивный backoff, более длинный retry-budget?
    3. Есть ли у компенсации идемпотентность? (если нет — это и причина; см. ../patterns/saga)
    4. Что должно было сработать в мониторинге раньше? (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 перестаёт работать.

Связанные разделы

Last updated on