Перейти к содержанию

Cookbook: Диагностика «застрявшего» intent

Практическое руководство для on-call инженера: как разобраться, почему intent не пришёл в терминальное состояние (SETTLED / FAILED), и когда уместно использовать админский endpoint POST /admin/intents/:id/resolve.

«Застрявший» — это любой intent, который провёл в нетерминальном статусе (CREATED, AUTHORIZED, MANUAL_REVIEW) дольше ожидаемого SLA канала. Для внутренних каналов норма — секунды; для PSP-каналов (IPPS) — до нескольких минут на confirm/inquiry.

1. Когда использовать этот рецепт

  • Клиент жалуется, что списание прошло, но статус остался AUTHORIZED.
  • Saga-worker / outbox-worker встал на этом intent (нет лога processed).
  • Метрика pm_intents_in_authorized показывает рост.
  • PSP webhook не пришёл / IPPS adapter упал во время confirm.
  • Стартовый reconciler (reconcile() в src/intent/startup-reconciler.ts) промаркировал stale intent в логах с manual review required.

Не используйте этот рецепт, если intent уже в SETTLED или FAILED — это терминальные состояния, проблема в чём-то другом (например, в downstream-нотификации).

2. Главное правило: trace_id = intent_id

PM логирует ВСЕ операции с полем intentId (см. src/shared/logger.ts — pino + structured logging). Эта же UUID используется как trace_id во всех кросс-сервисных вызовах: TigerBeetle (user_data_128), Redis pub/sub (intent.{id}), PSP-адаптерах. Поэтому первым шагом любого расследования всегда становится один и тот же UUID.

# pino-pretty в dev
kubectl logs deploy/payment-manager | grep '"intentId":"<UUID>"'

# production (json) — лучше через loki / kibana
{app="payment-manager"} | json | intentId="<UUID>"

Если в логах нет ни одной записи с этим intentId — intent не дошёл до PM. Проверяйте upstream (nginx → Auth Center → POST /intents), HMAC-подпись, network policy.

3. Шаги диагностики

Выполнять последовательно. После каждого шага решайте — продолжать или уже понятна причина.

Шаг 1. Найти intent и его текущее состояние

SELECT
  id, status, channel, operation_type,
  initiator_user_id, payer_account_name, payee_account_name,
  amount, currency, fee_amount,
  tb_transfer_ids,
  failure_reason,
  created_at, updated_at
FROM pm.intent
WHERE id = '<UUID>';

На что смотреть:

  • statusCREATED / AUTHORIZED / MANUAL_REVIEW (любое нетерминальное).
  • tb_transfer_idsNULL или пустой массив означает, что TB-операции даже не начались (intent застрял до authorize).
  • updated_at — давность последнего апдейта. Если разрыв с now() > 5 мин — startup reconciler уже должен был обработать.
  • failure_reason — иногда reconciler уже записал причину, но не успел сменить статус.

Шаг 2. Аудит-лог переходов (pm.intent_event)

intent_event — append-only журнал всех переходов статуса (см. src/shared/schema.ts, таблица intent_event). Это первичный источник правды о том, что именно происходило.

SELECT
  id, status_from, status_to, reason, payload, created_at
FROM pm.intent_event
WHERE intent_id = '<UUID>'
ORDER BY created_at ASC;

На что смотреть:

  • Полная цепочка status_from → status_to. Дыры в цепочке — признак того, что воркер крашнулся между шагами.
  • Последний переход + его created_at — это и есть момент «зависания».
  • payload обычно содержит решение rule-engine, fee splits или PSP-rqUID.
  • Если есть переход * → MANUAL_REVIEW — читайте reason, дальше скорее всего понадобится resolve (см. §5).

Шаг 3. Outbox queue для этого intent

outbox_event — очередь действий, которые должен выполнить OutboxWorker (post_pending / void_pending), см. src/shared/schema.ts:230.

SELECT
  id, action, status, payload, retry_count, created_at, processed_at
FROM pm.outbox_event
WHERE intent_id = '<UUID>'
ORDER BY created_at ASC;

На что смотреть:

  • status='pending' + старый created_at → OutboxWorker не подхватывает (упал? lease завис? Запущено больше одного экземпляра — нарушение правила NO-GO?).
  • status='failed' + растущий retry_count → читайте логи OutboxWorker с этим intentId.
  • Нет ни одной записи, а intent в AUTHORIZED → канал ждёт PSP webhook, переходите к шагу 4.
  • Есть processed запись, но intent остался в нетерминальном статусе → нашли рассинхронизацию (кандидат на resolve).

Шаг 4. PSP transaction map (только для PSP-каналов, в первую очередь IPPS)

psp_tx_map (см. src/shared/schema.ts:265) — состояние внешнего PSP-обмена: lookup → confirm → inquiry.

SELECT
  intent_id, psp_name, state,
  query_rq_uid, confirm_rq_uid, response_id,
  leased_by, leased_at, retry_count, last_error,
  settlement_date, psp_fee_satang,
  created_at, updated_at
FROM pm.psp_tx_map
WHERE intent_id = '<UUID>';

Карта состояний (PspState в schema.ts):

state Что это значит Что делать
NEW / QUERY_PENDING / QUERIED До confirm — TB ещё не тронут Обычно безопасно ждать или вручную FAIL
CONFIRM_PENDING Отправили confirm, ответа нет — orphan rqUID риск Проверить, не выполнился ли confirm у PSP (см. шаг 6 — логи IPPS-adapter)
INQUIRING Worker делает inquiry по confirm-rqUID Это норма; смотреть retry_count и last_error
CONFIRMED PSP подтвердил, ждём post_pending в TB Проверить outbox (шаг 3)
FAILED PSP вернул отказ Должна быть пара void_pending в outbox
MANUAL_REVIEW Терминальный orphan: confirm-rqUID потерян, inquiry не возможна Резолвить через POST /admin/intents/:id/resolve после ручной сверки с IPPS (§5)

Ключевые красные флаги:

  • leased_by не null + leased_at старее 1-2 минут → воркер взял lease и не освободил (краш / зависание pod).
  • retry_count упирается в потолок без смены state → инфра-проблема с PSP, не код.
  • state='MANUAL_REVIEW' → автоматически восстановить нельзя, нужна ops-сверка с IPPS-support.

Шаг 5. Дамп TigerBeetle transfers

PM хранит детерминированно сгенерированные UUID transfer'ов в intent.tb_transfer_ids. Конвертация UUID → TB-id выполняется через tbIdFromUuid() (src/ledger/id-gen.ts); парные id для post/void получаются XOR'ом (pendingId ^ 1n для post, ^ 2n для void — см. startup-reconciler.ts:53).

// Дамп всех связанных transfers через admin-tool / тестовый скрипт
import { getTb } from '../src/shared/tb.js'
import { tbIdFromUuid } from '../src/ledger/id-gen.js'

const uuids = ['<uuid1>', '<uuid2>']               // из intent.tb_transfer_ids
const pendingIds = uuids.map(tbIdFromUuid)
const postIds    = pendingIds.map(p => p ^ 1n)
const voidIds    = pendingIds.map(p => p ^ 2n)

const transfers = await getTb().lookupTransfers([...pendingIds, ...postIds, ...voidIds])
console.log(transfers)

На что смотреть:

  • pending-transfer существует, post — нет, void — нет → confirm в PSP прошёл, но post_pending не выполнился (рассинхронизация — кандидат на CONFIRMED-resolve).
  • pending + void → откат прошёл, intent должен быть FAILED (если нет — кандидат на FAILED-resolve).
  • pending + post → settle уже выполнен на уровне TB, intent должен быть SETTLED (рассинхронизация — кандидат на CONFIRMED-resolve).

Шаг 6. Логи через trace_id = intent_id

# Все сервисы (PM, IPPS-adapter, KYC-worker — все логируют intentId одинаково)
kubectl logs -l app=payment-manager       --since=1h | grep '"intentId":"<UUID>"'
kubectl logs -l app=ipps-adapter          --since=1h | grep '"intentId":"<UUID>"'
kubectl logs -l app=notifications-service --since=1h | grep '"intentId":"<UUID>"'

В production используйте Loki / ELK:

{app=~"payment-manager|ipps-adapter"} | json | intentId="<UUID>"

Полезные сообщения логгера (src/shared/logger.ts — pino):

  • reconcile: processing stale intents — startup-reconciler нашёл intent.
  • reconcile: void failed — TB вернул ошибку при попытке void (читайте errors[]).
  • reconcile: stale external intent — manual review required — внешний канал, авто-void запрещён.
  • resolve-intent: force-resolve applied — кто-то уже выполнял resolve по этому intent.

4. Auto-reconcile: что делает PM сам

Прежде чем дёргать админский endpoint — учитывайте, что у PM есть встроенный механизм восстановления.

Startup-reconciler (src/intent/startup-reconciler.ts):

  • Запускается при старте PM (на каждом deploy / рестарте pod'а).
  • Берёт intents в статусах CREATED / AUTHORIZED старше 5 минут.
  • Если tb_transfer_ids пусты → ставит FAILED (TB не тронут, можно безопасно).
  • Если канал внутренний (tbPendingTimeout > 0) → отправляет void_pending в TB, затем ставит FAILED.
  • Если канал внешний (PSP) → пишет в лог manual review required и НИЧЕГО не делает (нельзя автоматически решить за PSP).

Поэтому, если интенту меньше 5 минут — подождите. Если больше и канал внешний — переходите к §5.

5. Когда использовать POST /admin/intents/:id/resolve

Endpoint реализован в src/admin/resolve-intent.ts. Поднимает intent из MANUAL_REVIEW в терминальное состояние (SETTLED или FAILED). Идемпотентен — для уже SETTLED / FAILED возвращает 200, ничего не меняя.

5.1. Когда уместен

Только при ПОДТВЕРЖДЁННОЙ рассинхронизации между TB / PSP и pm.intent.status:

  • CONFIRMED-resolve: TB зафиксировал post-pending как committed (шаг 5 показал и pending, и post), но pm.intent.status остался AUTHORIZED / MANUAL_REVIEW → endpoint запишет post_pending в outbox, OutboxWorker переведёт intent в SETTLED.
  • FAILED-resolve: PSP вернул отказ (подтверждено в IPPS dashboard / support), но intent остался в AUTHORIZED / MANUAL_REVIEW → endpoint сразу выставит FAILED + поставит void_pending в outbox для очистки TB pending.
  • IPPS-orphan: psp_tx_map.state = MANUAL_REVIEW и ops-сверка с IPPS-support дала однозначный ответ (CONFIRMED или FAILED).

5.2. Когда НЕ уместен

  • Intent в CREATED / AUTHORIZED без MANUAL_REVIEW → endpoint вернёт 422 (WRONG_STATUS). Сначала переведите через канальный flow или дождитесь reconciler.
  • Если в TB и в PM согласованное состояние — resolve не нужен, разбирайтесь с downstream (нотификации, баланс на клиенте).
  • Если канал внутренний — там reconciler сам всё закроет, resolve не нужен.

5.3. Требования к вызову

Endpoint требует:

  • HMAC-аутентификации (X-Service-Id, X-Timestamp, X-Signature) — как и все вызовы в PM.
  • Permission forceResolve: true на service key (см. pm.service_key.permissions). По умолчанию выдаётся только admin-tool / on-call инструменту, не обычным сервисам.

Канал в tx_history для этих операций — ADMIN (НЕ ADMIN_TRANSFER).

5.4. Пример вызова

# CONFIRMED-resolve (TB committed, intent рассинхронизирован)
curl -X POST https://pm.internal/admin/intents/<UUID>/resolve \
  -H "X-Service-Id: admin-tool" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <hmac-sha256>" \
  -H "Content-Type: application/json" \
  -d '{"resolution":"CONFIRMED"}'

# FAILED-resolve (PSP отказал, нужно закрыть intent + void pending TB)
curl -X POST https://pm.internal/admin/intents/<UUID>/resolve \
  -H "X-Service-Id: admin-tool" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <hmac-sha256>" \
  -H "Content-Type: application/json" \
  -d '{"resolution":"FAILED","reason":"IPPS-support подтвердил REJECT, ticket #12345"}'

Коды ответа:

  • 201 — резолв применён.
  • 200 — intent уже в SETTLED / FAILED (идемпотентность).
  • 403 — у service key нет forceResolve.
  • 404 — intent не найден.
  • 422 — intent не в MANUAL_REVIEW.

6. Чек-лист завершения расследования

  • Собрана выборка из pm.intent, pm.intent_event, pm.outbox_event, pm.psp_tx_map (для PSP-каналов).
  • Сверены TB transfers через lookupTransfers() — состояния pending/post/void совпадают с intent.status.
  • Логи всех затронутых сервисов отфильтрованы по intentId и проверены за окно > 1 часа.
  • Решено: ждать reconciler / запустить resolve / эскалировать ops.
  • Если был resolve — записан reason, заведён инцидент, проверены downstream-эффекты (баланс пользователя, нотификации).

7. Связанные документы