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>';
На что смотреть:
status—CREATED/AUTHORIZED/MANUAL_REVIEW(любое нетерминальное).tb_transfer_ids—NULLили пустой массив означает, что 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:
Полезные сообщения логгера (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. Связанные документы¶
- ../architecture/03-intent-saga.md — полный жизненный цикл intent'а и переходы.
- ../modules/intent.md — как саги и OutboxWorker обрабатывают intent.
- ../api/admin.md — спецификация всех админских endpoint'ов.
- ./add-service-key.md — как выдать
forceResolvepermission новому ключу.