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

Runbook — типовые инциденты Payment Manager

Боевой runbook для дежурного инженера: симптомы → SQL/команды диагностики → действия по восстановлению. Все admin-вызовы требуют HMAC-подписи (X-Service-Id, X-Timestamp, X-Signature); генератор — node scripts/hmac-curl.js.


Назначение

Документ покрывает наиболее частые инцидентные сценарии PM, которые встречаются в эксплуатации: - залипшие intents (CREATED/AUTHORIZED/SETTLING/MANUAL_REVIEW), - расхождения TigerBeetle и pm.intent (нарушение инварианта transit.balance = 0), - накопление событий в pm.outbox_event, - залипшие invoice-интенты после прохождения expires_at, - залипшие строки в pm.psp_tx_map у psp-worker, - 503 от /health (стопор event loop), - накопление MANUAL_REVIEW без обработчика.

Для каждого сценария указаны конкретные SQL и команды; единственный admin-эндпоинт восстановления intents — POST /admin/intents/:id/resolve (см. секцию Force-Resolve).


Базовые команды

# Подпись HMAC-вызовов
node scripts/hmac-curl.js POST /admin/intents/<id>/resolve admin-panel \
  '{"resolution":"CONFIRMED"}' | bash

# Логи PM, фильтр по intentId
docker logs -f pm 2>&1 | grep '"intentId":"<intent-id>"'

# Подключение к postgres (через pgbouncer)
docker compose exec pgbouncer psql -U postgres -d onewallet

Сценарий 1 — Зависший intent (AUTHORIZED/SETTLING долго не двигается)

Симптомы

  • pm.intent.status = 'AUTHORIZED' дольше 5 минут на INTERNAL_P2P (должен схлопываться синхронно).
  • pm.intent.status = 'AUTHORIZED' часами на IPPS_TRANSFER без прогресса в pm.psp_tx_map.
  • Клиент сообщает: «деньги списались, но статус не финальный».

Диагностика

-- Состояние и время в статусе
SELECT id, status, channel, operation_type,
       amount, currency, failure_reason,
       array_length(tb_transfer_ids, 1) AS num_tb,
       created_at, updated_at,
       NOW() - updated_at AS age_in_status
FROM pm.intent
WHERE id = '<intent-id>';

-- Все переходы статуса
SELECT status_from, status_to, reason, payload, created_at
FROM pm.intent_event
WHERE intent_id = '<intent-id>'
ORDER BY created_at;

-- Outbox-задачи для этого intent
SELECT id, action, status, retry_count, processed_at, created_at,
       NOW() - created_at AS age
FROM pm.outbox_event
WHERE intent_id = '<intent-id>'
ORDER BY created_at;

Действия

  1. INTERNAL_P2P зависший в AUTHORIZED дольше 5 минут — handler аварийно завершился между PENDING и POST. Перезапуск PM (docker compose restart pm) запустит startup-reconciler (src/intent/startup-reconciler.ts), который выберет такие intents (status IN ('CREATED','AUTHORIZED') AND created_at < NOW() - INTERVAL '5 minutes') и для internal-каналов (tbPendingTimeout > 0) выпустит void_pending_transfer linked-batch, переводя intent в FAILED.
  2. IPPS_TRANSFER в AUTHORIZED часами — переходим в Сценарий 5.
  3. SETTLING не двигается — проверь pm.outbox_event для этого intent: если status='pending' и created_at старше 30 секунд — переходи в Сценарий 3.
  4. Если intent оказался в MANUAL_REVIEW — действия в Сценарий 7. Не «дёргать» POST /admin/intents/:id/resolve для intent в статусе AUTHORIZED — эндпоинт вернёт 422 WRONG_STATUS (src/admin/resolve-intent.ts:79-84).

Сценарий 2 — Рассинхронизация TB ↔ pm.intent (алерт BalanceMonitor)

Симптомы

  • В логах PM: event=balance_drift или event=low_partner_balance (src/workers/balance-monitor.ts:66-73).
  • В TigerBeetle: system.transit.<channel>.<ccy>.balance != 0 — критическое нарушение инварианта.
  • Расхождение нетто-баланса nostro в TB и баланса партнёра в PSP больше IPPS_DRIFT_THRESHOLD_SATANG (по умолчанию 10 000 сатангов = 100 THB).

Диагностика

# Алерты balance-monitor
docker logs pm 2>&1 | grep -E 'balance_drift|low_partner_balance'

Проверка нетто-баланса по transit-аккаунтам — выполнить в node-сессии внутри контейнера PM:

docker compose exec pm node -e "
import('./dist/shared/tb.js').then(async (m) => {
  const acc = await import('./dist/ledger/accounts.js')
  const tb  = m.getTb()
  const channels = ['INTERNAL_P2P','IPPS','MERCHANT','SERVICE_TRANSFER']
  const ids = channels.map(c =>
    acc.accountId(acc.accountName('TRANSIT',{channel:c,currency:'THB'})))
  const accs = await tb.lookupAccounts(ids)
  for (const [i,a] of accs.entries()) {
    const net = a.credits_posted - a.debits_posted
    console.log(channels[i], { net,
      pending_in:  a.credits_pending,
      pending_out: a.debits_pending })
  }
})
"

Для алерта balance_drift — сравнить TB nostro и баланс партнёра:

docker logs pm 2>&1 | grep balance_drift | tail -5
# смотреть поля tbNostroSatang, ippsBalanceSatang, driftSatang

Действия

  1. Транзитный аккаунт имеет net != 0 — НЕМЕДЛЕННАЯ эскалация. Деньги «висят». TigerBeetle гарантирует инвариант на каждой транзакции — net != 0 означает либо ручную правку, либо баг.
  2. balance_drift в пределах одного-двух transfer-ов — возможен false positive из-за non-atomic чтений TB и PSP balance (src/workers/balance-monitor.ts:48-50). Подождать следующий tick (BALANCE_TICK_MS).
  3. Устойчивый balance_drift — указывает на пропущенный inquiry либо двойное подтверждение в IPPS. Поиск виновного intent:
    SELECT id, intent_id, state, lookup_ref, settlement_date, psp_fee_satang
    FROM pm.psp_tx_map
    WHERE state IN ('CONFIRMED','MANUAL_REVIEW')
      AND created_at > NOW() - INTERVAL '24 hours'
    ORDER BY updated_at DESC;
    
  4. low_partner_balance — пополнить partner-аккаунт IPPS. PM продолжает приём intents, но они FAILED уйдут после первого fail driver-а.
  5. После исправления не вызывать POST /admin/intents/:id/resolve пока не подтверждена бизнес-сторона — forceResolve без понимания состояния создаст вторичное расхождение.

Сценарий 3 — Накопление в pm.outbox_event

Симптомы

  • В pm.outbox_event растёт количество status='pending'.
  • Intent-ы зависают в SETTLING, redis-события на канал intent.{id} молчат.
  • В логах PM нет привычного потока outbox-worker: batch/outbox-worker: poll.

Диагностика

-- Распределение по возрасту и action
SELECT action, status,
       COUNT(*) FILTER (WHERE NOW() - created_at < INTERVAL '30 seconds') AS fresh,
       COUNT(*) FILTER (WHERE NOW() - created_at BETWEEN INTERVAL '30 seconds' AND INTERVAL '5 minutes') AS warn,
       COUNT(*) FILTER (WHERE NOW() - created_at > INTERVAL '5 minutes') AS critical
FROM pm.outbox_event
WHERE status = 'pending'
GROUP BY action, status;

-- Топ-10 самых старых pending
SELECT id, intent_id, action, retry_count,
       NOW() - created_at AS age
FROM pm.outbox_event
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10;

-- Жёстко зависшие (retry_count много, payload содержит ошибку)
SELECT id, intent_id, action, retry_count, payload
FROM pm.outbox_event
WHERE status = 'pending' AND retry_count > 10
ORDER BY retry_count DESC;

Действия

  1. Worker не запущен — проверить WORKER_ROLES:
    docker compose exec pm env | grep WORKER_ROLES
    docker logs pm 2>&1 | grep -i 'outbox'
    
    Ожидаем: outbox-worker в списке ролей и регулярный outbox-worker: ... в логах. Если worker не запущен — перезапустить контейнер с правильным WORKER_ROLES.
  2. Множественные инстансы worker-а — запрещено по critical rules. Гарантировать единственный pod с outbox-worker.
  3. TB или DB вернули ошибкуdocker logs pm 2>&1 | grep -E 'outbox.*error|TB.*error'. Если одиночный intent зацикливает worker (retry_count растёт без потолка), вручную перевести его в FAILED после согласования с бизнесом.
  4. Восстановление — перезапуск PM (docker compose restart pm). Pickup идемпотентен; pending записи будут переобработаны от текущего состояния.

Сценарий 4 — Зависший invoice (статус CREATED после expires_at)

Симптомы

  • В pm.intent есть строки с operation_type='INVOICE_PAYMENT', status='CREATED' и expires_at < NOW().
  • В норме invoice-expiry job переводит их в EXPIRED (см. src/jobs/invoice-expiry.ts:28-70).
  • Mini-app получает «протухшие» invoices, плательщик видит CREATED у уже мёртвого invoice.

Диагностика

-- Просроченные invoices, всё ещё в CREATED
SELECT id, status, channel, expires_at, NOW() - expires_at AS overdue,
       created_at
FROM pm.intent
WHERE operation_type = 'INVOICE_PAYMENT'
  AND status = 'CREATED'
  AND expires_at < NOW()
ORDER BY expires_at
LIMIT 50;

-- Сколько всего просрочено
SELECT COUNT(*) FILTER (WHERE expires_at < NOW() - INTERVAL '5 minutes') AS overdue_5m,
       COUNT(*) FILTER (WHERE expires_at < NOW() - INTERVAL '1 hour')    AS overdue_1h
FROM pm.intent
WHERE operation_type = 'INVOICE_PAYMENT' AND status = 'CREATED';

Проверить, запущен ли sweep:

docker compose exec pm env | grep WORKER_ROLES
# Ожидаем: invoice-expiry в списке
docker logs pm 2>&1 | grep -i 'invoice-expiry'

Действия

  1. Sweep job не запущен — добавить invoice-expiry к WORKER_ROLES и перезапустить. По умолчанию sweep идёт каждые 30 000 мс, batch 100 (см. startInvoiceExpirySweep в src/jobs/invoice-expiry.ts:79-85).
  2. Sweep работает, но падаетdocker logs pm 2>&1 | grep 'invoice-expiry: failed'. Чаще всего: ошибка в channel.expire() из-за нарушения двухфазной модели. Проверить pm.intent_event для проблемного id.
  3. Ручной запуск expire для одиночных просроченных invoices (если sweep отстал и нужно срочно):
    docker compose exec pm node -e "
    import('./dist/jobs/invoice-expiry.js').then(async m => {
      const r = await m.processExpiredInvoices(500)
      console.log('processedCount', r.processedCount)
    })
    "
    
    processExpiredInvoices атомарен — даже несколько параллельных вызовов безопасны (expire() использует UPDATE WHERE status='CREATED'`).
  4. Не править статус UPDATE-ом руками — это сломает TB pending для двухфазных каналов и инвариант transit.balance = 0.

Сценарий 5 — Залипший pm.psp_tx_map у psp-worker

Симптомы

  • pm.psp_tx_map.state в *_PENDING (QUERY_PENDING/CONFIRM_PENDING) или INQUIRING много часов.
  • leased_by указывает на pod, который уже не существует, либо leased_at истекло.
  • IPPS-intents не закрываются в SETTLED/FAILED.

Диагностика

-- Залипшие в in-flight состояниях
SELECT id, intent_id, state, retry_count,
       leased_by, leased_at,
       NOW() - leased_at AS lease_age,
       last_error, created_at
FROM pm.psp_tx_map
WHERE state IN ('NEW','QUERY_PENDING','QUERIED','CONFIRM_PENDING','INQUIRING')
  AND psp_name = 'IPPS'
ORDER BY created_at
LIMIT 50;

-- Истёкшие lease (worker умер, но lease ещё не release)
SELECT id, intent_id, state, leased_by,
       NOW() - leased_at AS stale_for
FROM pm.psp_tx_map
WHERE leased_at IS NOT NULL
  AND leased_at < NOW() - INTERVAL '60 seconds'
  AND state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
ORDER BY leased_at;

-- В MANUAL_REVIEW (для отдельного scenario 7)
SELECT id, intent_id, state, last_error, created_at
FROM pm.psp_tx_map
WHERE state = 'MANUAL_REVIEW'
ORDER BY created_at;

Действия

  1. Worker не запущенdocker compose exec pm env | grep WORKER_ROLES должен содержать psp-worker. Логи: docker logs pm 2>&1 | grep 'PspWorker'.
  2. Lease истёк, но pickup не подбирает — pickup-CTE подбирает строки с leased_at < NOW() - PSP_LEASE_SEC (10 c по умолчанию) и retry-leases (PSP_RETRY_LEASE_SEC 30 c). Если lease сильно старше — проверить, что worker действительно дёргает таблицу, и нет ли в логах ошибки postgres-js (см. PASSPORT.md «Drizzle / postgres-js gotcha»).
  3. Принудительное снятие lease — допустимо только при подтверждённой смерти worker-а:
    UPDATE pm.psp_tx_map
    SET leased_at = NULL, leased_by = NULL
    WHERE id = '<row-id>' AND state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
      AND leased_at < NOW() - INTERVAL '5 minutes';
    
    После следующего polling-цикла worker подберёт строку.
  4. Бесконечный retry — у retry_count нет верхнего потолка автоматического перевода в FAILED. Если last_error указывает на нерешаемую проблему — вручную перевести в MANUAL_REVIEW:
    UPDATE pm.psp_tx_map
    SET state = 'MANUAL_REVIEW',
        last_error = 'ops: forced review (укажи причину)',
        leased_at = NULL, leased_by = NULL
    WHERE id = '<row-id>';
    
    Затем — Сценарий 7.

Сценарий 6 — 503 от /health (event loop stalled)

Симптомы

  • curl http://localhost:3000/health возвращает 503.
  • Liveness probe в Kubernetes/Docker рестартит контейнер.
  • В логах PM долгие отсутствия активности, потом «всплеск».

Диагностика

# Прямая проверка
curl -i http://localhost:3000/health
# Ожидаем 200 { "status": "ok" }
# 503 ⇒ eventLoopDelay > 1000ms

# Метрики node event loop (если есть)
docker logs pm 2>&1 | grep -i 'event.*loop'

# CPU/нагрузка контейнера
docker stats pm --no-stream

Параллельно — проверить TB и DB как корни блока:

# TB heartbeat: попытка lookup
docker compose exec pm node -e "
import('./dist/shared/tb.js').then(async m => {
  const tb = m.getTb()
  console.time('tb-lookup')
  await tb.lookupAccounts([1n])
  console.timeEnd('tb-lookup')
})
"

# Postgres latency
docker compose exec pgbouncer psql -U postgres -c "SELECT pg_sleep(0)"

Действия

  1. TB lookup > 500 ms или таймауты — TB кластер деградировал. Эскалация TB-команде; PM рестартить смысла нет.
  2. DB lock/long query — найти и убить долгие транзакции:
    SELECT pid, NOW() - xact_start AS xact_age, state, query
    FROM pg_stat_activity
    WHERE xact_start < NOW() - INTERVAL '30 seconds'
      AND state <> 'idle'
    ORDER BY xact_start;
    
  3. Sync CPU burn в PM — например, large fee-rule eval. Поднять log level до debug через admin-эндпоинт debug-level (см. секцию «Real-Time Debug Mode» в этом runbook) и наблюдать fee-calculator/outbox-worker/psp-worker batch sizes.
  4. Восстановлениеdocker compose restart pm. Graceful shutdown отработает onClose-хуки (TB/Redis/таймеры workers).
  5. Профилактика — добавить --max-old-space-size или вынести psp-worker/outbox-worker в отдельные pod-ы, чтобы api-pod не тормозил под нагрузкой воркеров.

Сценарий 7 — Накопление MANUAL_REVIEW intents

Симптомы

  • pm.intent.status = 'MANUAL_REVIEW' растёт.
  • В логах: event=manual_review_required.
  • IPPS подтверждает / опровергает движение денег по lookup_ref, нужно закрыть intent.

Диагностика

-- Все MANUAL_REVIEW intents с контекстом PSP
SELECT i.id, i.channel, i.operation_type, i.amount, i.currency,
       i.created_at, NOW() - i.created_at AS age,
       p.state AS psp_state, p.lookup_ref, p.confirm_rq_uid, p.last_error
FROM pm.intent i
LEFT JOIN pm.psp_tx_map p ON p.intent_id = i.id
WHERE i.status = 'MANUAL_REVIEW'
ORDER BY i.created_at;

-- Проверка прав сервиса
SELECT service_id, active, permissions
FROM pm.service_key
WHERE service_id = 'admin-panel';

Поле permissions.forceResolve должно быть true — иначе эндпоинт вернёт 403 (src/admin/resolve-intent.ts:54-56).

Действия

  1. Выдать forceResolve сервису admin-panel (если ещё не выдано):
    UPDATE pm.service_key
    SET permissions = jsonb_set(permissions, '{forceResolve}', 'true'::jsonb)
    WHERE service_id = 'admin-panel';
    
  2. Подтвердить факт движения денег в IPPS поддержке через lookup_ref и confirm_rq_uid.
  3. Если деньги ушли — закрыть intent как CONFIRMED:
    node scripts/hmac-curl.js POST /admin/intents/<intent-id>/resolve admin-panel \
      '{"resolution":"CONFIRMED"}' | bash
    
    Эффект (src/admin/resolve-intent.ts:86-93): вставляет outbox_event(action='post_pending', status='pending'). OutboxWorker выполнит TB POST_PENDING, переведёт intent в SETTLED, запишет tx_history, опубликует Redis-событие intent.{id}.
  4. Если деньги НЕ ушли — закрыть как FAILED:
    node scripts/hmac-curl.js POST /admin/intents/<intent-id>/resolve admin-panel \
      '{"resolution":"FAILED","reason":"IPPS support: tx did not reach bank"}' | bash
    
    Эффект (src/admin/resolve-intent.ts:94-111): транзакционно ставит intent.status='FAILED' + failure_reason + вставляет outbox_event(action='void_pending'), который освобождает TB pending.
  5. Идемпотентность — повторный вызов на уже SETTLED/FAILED intent возвращает 200 с previousStatus, без побочных эффектов. На intent в любом другом нерезолвящем статусе (CREATED/AUTHORIZED/SETTLING) — 422 WRONG_STATUS. НЕ использовать этот эндпоинт для «спасения» неманюальных intents — использовать сценарии выше.

Чек-лист дежурного перед эскалацией

  1. Зафиксировать intent-id / pm.psp_tx_map.id / pm.outbox_event.id.
  2. Сохранить вывод SQL-диагностики из соответствующего сценария.
  3. Сохранить релевантные строки docker logs pm (фильтр по intentId).
  4. Не выполнять UPDATE pm.intent SET status = ... напрямую — это сломает аудит-трейл и инвариант TB.
  5. Любые ручные действия в pm.psp_tx_map фиксировать в pm.intent_event (через INSERT с reason='ops manual ...').
  6. После восстановления — проверить transit.balance = 0 командой из Сценария 2.

См. также: operations/health-checks.md, operations/monitoring.md, operations/worker-roles.md.