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

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', после FAILEDvoid_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:

  1. pickUpJobs() (см. src/workers/psp-worker.ts:44-103) одной CTE-транзакцией атомарно:
  2. выбирает кандидатов через SELECT ... FOR UPDATE SKIP LOCKED LIMIT 10;
  3. на лету проставляет leased_by = $WORKER_ID, leased_at = now(), state = NEW→QUERY_PENDING / QUERIED→CONFIRM_PENDING.
  4. После коммита pickup'а соседние воркеры увидят новый state и не заберут строку повторно.
  5. 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_MSsrc/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_idpm.intent.id (логический FK, без REFERENCES — см. 02-intent.md). UNIQUE-констрейнт гарантирует 1:1.
  • pm.outbox_event.intent_idapplyOutcome() пишет 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_idloadContext() читает 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'е

SELECT state, count(*)
FROM pm.psp_tx_map
WHERE psp_name = 'IPPS'
GROUP BY state
ORDER BY 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;

История state-переходов для конкретного intent'а

SELECT created_at, status_from, status_to, reason, payload->'psp' AS psp_audit
FROM pm.intent_event
WHERE intent_id = $1
ORDER BY created_at;