PostgreSQL-очередь PSP-операций (Phase 1): хранит контекст IPPS-транзакции для конкретного intent'а, обслуживается воркером psp-worker через FOR UPDATE SKIP LOCKED с lease-семантикой.
pm.psp_tx_map¶
Назначение¶
pm.psp_tx_map — единственный source-of-truth для состояния PSP-стороны платежа в Phase 1. На каждый intent, требующий обращения к PSP (IPPS), создаётся ровно одна строка (intent_id UNIQUE). Воркер psp-worker атомарно «забирает» готовые строки, прогоняет их через драйвер PSP (src/psp/ipps/) и продвигает state-машину PspState до терминального состояния (CONFIRMED / FAILED / MANUAL_REVIEW). После CONFIRMED пишется outbox_event.action='post_pending', после FAILED — void_pending; оба обрабатываются OutboxWorker'ом и финализируют TigerBeetle-перевод.
Таблица одновременно играет роль persistent queue: воркер не использует Redis/BullMQ — все «джобы» лежат в самой таблице, выбираются по фильтру state IN (...) с учётом lease.
Заготовка на будущее: Phase 2B заменит PostgreSQL-очередь на Redis Streams (
stream.ipps.jobs/stream.ipps.results, см. корневойCLAUDE.md). Таблицаpsp_tx_mapостанется как audit/state-хранилище, но pickup-цикл сFOR UPDATE SKIP LOCKEDбудет заменён consumer group'ой Redis. До этой миграции вся координация выполняется через PostgreSQL.Заготовка на будущее: колонка
psp_nameуже расширена типом'IPPS' | 'QP' | 'WISE'и CHECK-констрейнтом, но в Phase 1 драйвер реализован только дляIPPS.QP(QR-payments / PromptPay) иWISE(международные переводы) — это слоты под будущие PSP-интеграции, активного кода пока нет (src/psp/registry.tsпробрасывает только IPPS-драйвер).
DDL¶
Цитата из drizzle/migrations/0000_init.sql (строки 78–101):
CREATE TABLE "pm"."psp_tx_map" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"intent_id" uuid NOT NULL,
"psp_name" text NOT NULL,
"state" text DEFAULT 'NEW' NOT NULL,
"query_rq_uid" text,
"lookup_ref" text,
"receiver_name_en" text,
"receiver_display_name" text,
"receiver_bank" text,
"confirm_rq_uid" text,
"response_id" text,
"settlement_date" text,
"psp_fee_satang" integer,
"leased_by" text,
"leased_at" timestamp with time zone,
"retry_count" integer DEFAULT 0 NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "psp_tx_map_intent_id_unique" UNIQUE("intent_id"),
CONSTRAINT "psp_tx_map_state_chk"
CHECK ("pm"."psp_tx_map"."state" IN
('NEW','QUERY_PENDING','QUERIED','CONFIRM_PENDING','INQUIRING','CONFIRMED','FAILED','MANUAL_REVIEW')),
CONSTRAINT "psp_tx_map_psp_chk"
CHECK ("pm"."psp_tx_map"."psp_name" IN ('IPPS','QP','WISE'))
);
Тюнинг из drizzle/migrations/0001_perf-tuning.sql:
ALTER TABLE "pm"."psp_tx_map" SET (
fillfactor = 85,
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.05
);
fillfactor=85 оставляет место в странице под HOT-апдейты state-машины (частые UPDATE state, leased_at), агрессивный autovacuum (5%) подавляет bloat от высокой churn rate.
Поля¶
| Колонка | Тип | NULL | Описание |
|---|---|---|---|
id |
uuid |
NO | PK, gen_random_uuid(). |
intent_id |
uuid |
NO | Ссылка на pm.intent.id. UNIQUE — один PSP-tx на intent. См. 02-intent.md. |
psp_name |
text ($type<PspName>()) |
NO | Идентификатор PSP: IPPS, QP, WISE. В Phase 1 активен только IPPS. |
state |
text ($type<PspState>()) |
NO, default 'NEW' |
Состояние PSP-машины (см. ниже). |
query_rq_uid |
text |
YES | rqUID, отправленный в IPPS Q-IPPS-13 (lookup/inquiry). |
lookup_ref |
text |
YES | Референс результата lookup от IPPS (используется при manual review). |
receiver_name_en |
text |
YES | Имя получателя (en) из ответа IPPS Q-IPPS-13. |
receiver_display_name |
text |
YES | Display-имя получателя (для отображения отправителю). |
receiver_bank |
text |
YES | Код банка получателя (см. src/psp/ipps/bank-codes.ts). |
confirm_rq_uid |
text |
YES | rqUID confirm-запроса в IPPS (T-IPPS-04). |
response_id |
text |
YES | responseId из ответа IPPS на confirm. |
settlement_date |
text |
YES | Дата расчёта от IPPS в формате YYYYMMDD. |
psp_fee_satang |
integer |
YES | Себестоимость операции у PSP (в сатангах). Не путать с intent.fee_amount — это плата клиента. |
leased_by |
text |
YES | Worker ID (hostname.pid), удерживающий lease. |
leased_at |
timestamptz |
YES | Момент захвата lease. NULL после освобождения (applyOutcome). |
retry_count |
integer |
NO, default 0 |
Счётчик ретраев in-progress-исходов. Сбрасывается в 0 при CONFIRMED/FAILED. |
last_error |
text |
YES | Последний код ошибки / причина (intent_not_found, wallet_not_registered, и т.д.). |
created_at |
timestamptz |
NO | Создание строки. |
updated_at |
timestamptz |
NO | Обновляется на каждом state-transition'е приложением (не триггером). |
Значения PspState¶
Из src/shared/schema.ts:252-263 (CHECK-констрейнт в DDL должен совпадать дословно):
| State | Семантика |
|---|---|
NEW |
Только что создана (вставка из intent.controller / sagi). Воркер заберёт первой же итерацией. |
QUERY_PENDING |
Запрос Q-IPPS-13 (lookup) отправлен; ждём ответа или истечения lease. |
QUERIED |
Lookup-ответ получен и сохранён (receiver_*, lookup_ref). Следующий тик переведёт в CONFIRM_PENDING. |
CONFIRM_PENDING |
Запрос T-IPPS-04 (confirm) отправлен; ждём ответа. |
INQUIRING |
После сбоя/таймаута confirm выполняется inquiry-проверка статуса (Q-IPPS-13 положительная ветка). |
CONFIRMED |
Терминальный успех. Параллельно вставлен outbox_event.action='post_pending'. |
FAILED |
Терминальный отказ. Вставлен outbox_event.action='void_pending'. |
MANUAL_REVIEW |
Терминал-pending (см. комментарий в schema.ts:260-262): orphan-rqUID после краша между confirm-send и persist. Воркер не может авто-резолвить (inquiry принимает только confirm-rqUID, который потерян). Ops через IPPS-support вручную переводит в CONFIRMED/FAILED. |
Lease-семантика¶
Чтобы избежать дублирующей обработки между несколькими процессами psp-worker:
pickUpJobs()(см.src/workers/psp-worker.ts:44-103) одной CTE-транзакцией атомарно:- выбирает кандидатов через
SELECT ... FOR UPDATE SKIP LOCKED LIMIT 10; - на лету проставляет
leased_by = $WORKER_ID,leased_at = now(),state = NEW→QUERY_PENDING/QUERIED→CONFIRM_PENDING. - После коммита pickup'а соседние воркеры увидят новый state и не заберут строку повторно.
applyOutcome()при любом исходе обнуляетleased_at = NULL— это сигнал «свободна, можно брать сразу» (важно для retry-сценариев, см. live SIT-комментарий вpsp-worker.ts:56-58).
Условия повторного pickup'а строки в *_PENDING:
leased_at IS NULL— applyOutcome сбросил lease;retry_count = 0Иleased_at < now() - PSP_LEASE_SEC— первичный таймаут;retry_count >= 1Иleased_at < now() - PSP_RETRY_LEASE_SEC— продлённый таймаут для уже ретрайнутых строк (защита от шторма).
Конфиг: PSP_LEASE_SEC, PSP_RETRY_LEASE_SEC, PSP_POLL_INTERVAL_MS — src/shared/config.ts.
Индексы¶
| Имя | Колонки | Тип | Назначение |
|---|---|---|---|
psp_tx_map_pkey |
id |
PK btree | Первичный ключ. |
psp_tx_map_intent_id_unique |
intent_id |
UNIQUE btree | Гарантирует один PSP-tx на intent + ускоряет lookup по intent_id. |
psp_tx_map_query_rq_uid_idx |
query_rq_uid |
btree | Поиск по rqUID при разборе ответов/инцидентов. |
psp_tx_map_confirm_rq_uid_idx |
confirm_rq_uid |
btree | То же для confirm-rqUID. |
psp_tx_map_active_idx |
(psp_name, created_at) PARTIAL |
btree | HOT-индекс для pickUpJobs(). WHERE state IN ('NEW','QUERY_PENDING','QUERIED','CONFIRM_PENDING','INQUIRING') — терминалы не индексируются, размер минимален. |
Связи¶
intent_id→pm.intent.id(логический FK, безREFERENCES— см.02-intent.md). UNIQUE-констрейнт гарантирует 1:1.pm.outbox_event.intent_id—applyOutcome()пишетpost_pending/void_pendingсобытия в outbox, привязанные к тому жеintent_id. См.09-outbox-event.md.pm.intent_event.intent_id— на каждый state-transition пишется аудит-запись (status_from,status_to,payload.psp). См.10-intent-event.md.pm.tb_account_map.user_id—loadContext()читаетipps_wallet_idпользователя; отсутствие приводит кMANUAL_REVIEWсlast_error='wallet_not_registered'.
Связанный код¶
| Путь | Что делает |
|---|---|
src/shared/schema.ts:251-300 |
Определения PspName, PspState, таблица pspTxMap, типы PspTxMap/NewPspTxMap. |
src/workers/psp-worker.ts |
Воркер: pickUpJobs, loadContext, escalateMissingContext, applyOutcome, processPspBatch, startPspWorker. |
src/psp/registry.ts |
Резолвит PspName → Driver. Phase 1 — только IPPS. |
src/psp/ipps/driver.ts |
Реализация process() для IPPS: Q-IPPS-13 → T-IPPS-04 → inquiry. |
src/psp/ipps/client.ts |
HTTP-клиент IPPS. |
src/psp/ipps/bank-codes.ts |
Справочник кодов банков для receiver_bank. |
src/psp/ipps/error.ts |
Классификация ошибок IPPS (retry vs terminal vs manual-review). |
src/psp/types.ts |
PspProcessInput, PspProcessOutcome — контракт между воркером и драйвером. |
drizzle/migrations/0000_init.sql |
DDL. |
drizzle/migrations/0001_perf-tuning.sql |
fillfactor, autovacuum, partial index. |
Примеры запросов¶
Сколько активных PSP-tx в каждом state'е¶
Найти зависшие строки (lease истёк, но retry-count низкий)¶
SELECT id, intent_id, state, retry_count, leased_by, leased_at, last_error
FROM pm.psp_tx_map
WHERE state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
AND leased_at < now() - interval '5 minutes'
ORDER BY leased_at
LIMIT 50;
Строки, требующие ручного разбора (Ops dashboard)¶
SELECT id, intent_id, last_error, lookup_ref, confirm_rq_uid, updated_at
FROM pm.psp_tx_map
WHERE state = 'MANUAL_REVIEW'
ORDER BY updated_at DESC;
Pickup-эмуляция (НЕ запускать в production без понимания)¶
-- Тот же фильтр, что использует pickUpJobs(), но без UPDATE.
SELECT id, intent_id, state, leased_at, retry_count
FROM pm.psp_tx_map
WHERE psp_name = 'IPPS'
AND (
state IN ('NEW','QUERIED')
OR (state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING') AND leased_at IS NULL)
OR (state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
AND retry_count = 0 AND leased_at < now() - interval '30 seconds')
OR (state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
AND retry_count >= 1 AND leased_at < now() - interval '5 minutes')
)
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;