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;
Действия¶
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_transferlinked-batch, переводя intent вFAILED.IPPS_TRANSFERв AUTHORIZED часами — переходим в Сценарий 5.SETTLINGне двигается — проверьpm.outbox_eventдля этого intent: еслиstatus='pending'иcreated_atстарше 30 секунд — переходи в Сценарий 3.- Если intent оказался в
MANUAL_REVIEW— действия в Сценарий 7. Не «дёргать»POST /admin/intents/:id/resolveдля intent в статусеAUTHORIZED— эндпоинт вернёт 422WRONG_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).
Диагностика¶
Проверка нетто-баланса по 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
Действия¶
- Транзитный аккаунт имеет
net != 0— НЕМЕДЛЕННАЯ эскалация. Деньги «висят». TigerBeetle гарантирует инвариант на каждой транзакции —net != 0означает либо ручную правку, либо баг. balance_driftв пределах одного-двух transfer-ов — возможен false positive из-за non-atomic чтений TB и PSP balance (src/workers/balance-monitor.ts:48-50). Подождать следующий tick (BALANCE_TICK_MS).- Устойчивый
balance_drift— указывает на пропущенный inquiry либо двойное подтверждение в IPPS. Поиск виновного intent: low_partner_balance— пополнить partner-аккаунт IPPS. PM продолжает приём intents, но они FAILED уйдут после первого fail driver-а.- После исправления не вызывать
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;
Действия¶
- Worker не запущен — проверить
WORKER_ROLES: Ожидаем:outbox-workerв списке ролей и регулярныйoutbox-worker: ...в логах. Если worker не запущен — перезапустить контейнер с правильнымWORKER_ROLES. - Множественные инстансы worker-а — запрещено по critical rules. Гарантировать единственный pod с
outbox-worker. - TB или DB вернули ошибку —
docker logs pm 2>&1 | grep -E 'outbox.*error|TB.*error'. Если одиночный intent зацикливает worker (retry_count растёт без потолка), вручную перевести его в FAILED после согласования с бизнесом. - Восстановление — перезапуск PM (
docker compose restart pm). Pickup идемпотентен; pending записи будут переобработаны от текущего состояния.
Сценарий 4 — Зависший invoice (статус CREATED после expires_at)¶
Симптомы¶
- В
pm.intentесть строки сoperation_type='INVOICE_PAYMENT',status='CREATED'иexpires_at < NOW(). - В норме
invoice-expiryjob переводит их в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'
Действия¶
- Sweep job не запущен — добавить
invoice-expiryкWORKER_ROLESи перезапустить. По умолчанию sweep идёт каждые 30 000 мс, batch 100 (см.startInvoiceExpirySweepвsrc/jobs/invoice-expiry.ts:79-85). - Sweep работает, но падает —
docker logs pm 2>&1 | grep 'invoice-expiry: failed'. Чаще всего: ошибка вchannel.expire()из-за нарушения двухфазной модели. Проверитьpm.intent_eventдля проблемного id. - Ручной запуск 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'`). - Не править статус 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;
Действия¶
- Worker не запущен —
docker compose exec pm env | grep WORKER_ROLESдолжен содержатьpsp-worker. Логи:docker logs pm 2>&1 | grep 'PspWorker'. - Lease истёк, но pickup не подбирает — pickup-CTE подбирает строки с
leased_at < NOW() - PSP_LEASE_SEC(10 c по умолчанию) и retry-leases (PSP_RETRY_LEASE_SEC30 c). Если lease сильно старше — проверить, что worker действительно дёргает таблицу, и нет ли в логах ошибки postgres-js (см. PASSPORT.md «Drizzle / postgres-js gotcha»). - Принудительное снятие lease — допустимо только при подтверждённой смерти worker-а: После следующего polling-цикла worker подберёт строку.
- Бесконечный retry — у
retry_countнет верхнего потолка автоматического перевода в FAILED. Еслиlast_errorуказывает на нерешаемую проблему — вручную перевести вMANUAL_REVIEW:Затем — Сценарий 7.UPDATE pm.psp_tx_map SET state = 'MANUAL_REVIEW', last_error = 'ops: forced review (укажи причину)', leased_at = NULL, leased_by = NULL WHERE id = '<row-id>';
Сценарий 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)"
Действия¶
- TB lookup > 500 ms или таймауты — TB кластер деградировал. Эскалация TB-команде; PM рестартить смысла нет.
- DB lock/long query — найти и убить долгие транзакции:
- Sync CPU burn в PM — например, large fee-rule eval. Поднять log level до
debugчерез admin-эндпоинт debug-level (см. секцию «Real-Time Debug Mode» в этом runbook) и наблюдатьfee-calculator/outbox-worker/psp-workerbatch sizes. - Восстановление —
docker compose restart pm. Graceful shutdown отработаетonClose-хуки (TB/Redis/таймеры workers). - Профилактика — добавить
--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).
Действия¶
- Выдать
forceResolveсервису admin-panel (если ещё не выдано): - Подтвердить факт движения денег в IPPS поддержке через
lookup_refиconfirm_rq_uid. - Если деньги ушли — закрыть intent как
CONFIRMED:Эффект (node scripts/hmac-curl.js POST /admin/intents/<intent-id>/resolve admin-panel \ '{"resolution":"CONFIRMED"}' | bashsrc/admin/resolve-intent.ts:86-93): вставляетoutbox_event(action='post_pending', status='pending'). OutboxWorker выполнит TB POST_PENDING, переведёт intent вSETTLED, запишетtx_history, опубликует Redis-событиеintent.{id}. - Если деньги НЕ ушли — закрыть как
FAILED:Эффект (node scripts/hmac-curl.js POST /admin/intents/<intent-id>/resolve admin-panel \ '{"resolution":"FAILED","reason":"IPPS support: tx did not reach bank"}' | bashsrc/admin/resolve-intent.ts:94-111): транзакционно ставитintent.status='FAILED'+failure_reason+ вставляетoutbox_event(action='void_pending'), который освобождает TB pending. - Идемпотентность — повторный вызов на уже
SETTLED/FAILEDintent возвращает 200 сpreviousStatus, без побочных эффектов. На intent в любом другом нерезолвящем статусе (CREATED/AUTHORIZED/SETTLING) — 422WRONG_STATUS. НЕ использовать этот эндпоинт для «спасения» неманюальных intents — использовать сценарии выше.
Чек-лист дежурного перед эскалацией¶
- Зафиксировать
intent-id/pm.psp_tx_map.id/pm.outbox_event.id. - Сохранить вывод SQL-диагностики из соответствующего сценария.
- Сохранить релевантные строки
docker logs pm(фильтр поintentId). - Не выполнять
UPDATE pm.intent SET status = ...напрямую — это сломает аудит-трейл и инвариант TB. - Любые ручные действия в
pm.psp_tx_mapфиксировать вpm.intent_event(через INSERT сreason='ops manual ...'). - После восстановления — проверить
transit.balance = 0командой из Сценария 2.
См. также: operations/health-checks.md, operations/monitoring.md, operations/worker-roles.md.