Артефакт 8/10

Cross-DB и межброкерские операции

Три базы данных. Одна атомарная операция. Saga pattern + outbox table решают проблему распределённых транзакций между тенантами.

1 Проблема: три записи в три базы атомарно

MySQL не поддерживает распределённые транзакции между разными инстансами

Проблема: Когда межброкерская операция завершается, нужно атомарно записать данные в 3 разных базы: ObligationEntity в мастер-БД + TransactionEntity в тенант Альфы + TransactionEntity в тенант Беты. Если любая из трёх записей провалится — нужно откатить все.
Мастер-БД (fastotc)

Платформа

ObligationEntity
debtor=Бр1, creditor=Бр2, amount, currency
Тенант Альфы

Брокер 1

PartnerEntity (type=network) для Бр2
TransactionEntity tx_type=Debit
Тенант Беты

Брокер 2

PartnerEntity (type=network) для Бр1
TransactionEntity tx_type=Credit

2 Четырёхшаговый межброкерский протокол

NetworkRequestEntity: pending → accepted → sent → completed. Нажмите на шаг для деталей.

PENDING

Заявка создана

ACCEPTED

Контрагент принял

SENT

Деньги отправлены

COMPLETED

Подтверждено ✓

Шаг 1: PENDING — Создание заявки

Брокер-автор создаёт NetworkRequestEntity с типом (send/receive), суммой, валютой, методом.

NetworkService::createRequest(
  author_broker_id: 1,       // Альфа
  target_broker_id: 2,       // Бета (или null = broadcast)
  type: 'send',
  currency_code: 'USD',
  amount: 10000,
  method: 'cash',
  city_code: 'DXB'
)
→ NR-0A5BB4, status: pending

Шаг 2: ACCEPTED — Контрагент принял

Целевой брокер видит заявку и подтверждает готовность. Фиксируются условия.

NetworkService::acceptRequest(request_id: 'NR-0A5BB4', broker_id: 2)
→ status: accepted, accepted_at: now()

// Альтернативы: rejected, cancelled, expired (по deadline_at)

Шаг 3: SENT — Деньги отправлены

Автор отмечает отправку с proof (фото, tx_hash крипто, номер перевода).

NetworkService::markSent(request_id: 'NR-0A5BB4', proof: {
  type: 'photo',
  url: '/uploads/proof-xyz.jpg',
  note: 'Передал курьеру Ахмеду в 14:30'
})
→ status: sent, sent_at: now()

Шаг 4: COMPLETED — Подтверждение получения

Целевой брокер подтверждает получение. Именно здесь происходит атомарная запись в 3 базы.

NetworkService::confirmReceived(request_id: 'NR-0A5BB4', broker_id: 2)

// Атомарная операция (Saga):
Мастер-БД:  ObligationEntity (debtor=1, creditor=2, $10K, active)
Тенант Бр1: TransactionEntity (debit, $10K, hash=SHA256(...))
Тенант Бр2: TransactionEntity (credit, $10K, hash=SHA256(...))

→ status: completed, completed_at: now()
Дополнительные статусы: rejected (отклонена), cancelled (автор отменил), expired (по deadline_at), disputed (спор — арбитраж через хэш-цепочку).

3 Saga Pattern: пошаговая атомарность

Каждый шаг идемпотентен. При ошибке — компенсирующая транзакция.

1

Записать ObligationEntity

Мастер-БД

status = pending. Это "резервация" обязательства. Ещё не активно.

2

Записать TransactionEntity в тенант Бр1

Тенант Альфы

Дебет на сумму операции. Обновить AccountEntity. Записать tx_hash.

3

Записать TransactionEntity в тенант Бр2

Тенант Беты

Кредит на сумму операции. Обновить AccountEntity. Записать tx_hash.

4

Активировать ObligationEntity

Мастер-БД

status = active. Все три записи успешны — обязательство активно.

!

При ошибке на шаге 2 или 3:

Компенсирующая транзакция: удалить/откатить записи из предыдущих шагов. ObligationEntity → status = failed. Retry через outbox worker.

4 Outbox Table: гарантия доставки

Каждый шаг Saga записывается в outbox. Async worker обрабатывает очередь.

id saga_id step target_db payload status retries
1 SAGA-001 1 master ObligationEntity {debtor:1, creditor:2, $10K} done 0
2 SAGA-001 2 tenant_1 TransactionEntity {type:debit, $10K} done 0
3 SAGA-001 3 tenant_2 TransactionEntity {type:credit, $10K} pending 2
4 SAGA-001 4 master Obligation.status = active waiting 0
OutboxEntity (мастер-БД, таблица: exchange_outbox)
  id              int, PK, auto-increment
  saga_id         string, indexed          // группирует шаги одной операции
  step            int                      // порядковый номер шага
  target_db       string                   // master | tenant_{broker_id}
  entity_class    string                   // ObligationEntity | TransactionEntity
  payload         json                     // данные для записи
  status          enum: pending|done|failed // статус выполнения
  retries         int, default 0           // количество попыток
  max_retries     int, default 5
  error_message   text, nullable
  created_at      datetime
  processed_at    datetime, nullable

// Worker: каждые N секунд берёт pending записи,
// выполняет в порядке step, обновляет status.
// При max_retries → alert + manual resolution.

5 Бизнес-сценарии межброкерских операций

Нажмите на сценарий для деталей

Схема Ж: Внесение через чужого кассира
Схема Д: Выдача через чужого кассира
Кросс-брокерский перевод

Схема Ж: Клиент Альфы вносит деньги через кассира Беты

Вася — клиент Альфы. Он в Стамбуле, а у Альфы нет кассира там. Бета есть.

1. Альфа создаёт OrderEntity (type=collect, executor_broker=Бета)
   counterpart_uid: FO-7a3f8b2c4d1e (Вася)
   instrument: RUB-CASH, amount: ₽47,500

2. Кассир Беты получает Васю (FO-7a3f...) → принимает ₽47,500

3. Атомарная запись (Saga):
   Тенант Альфы:  AccountEntity (Вася, RUB) += 47,500
   Тенант Беты:   AccountEntity (кассир, RUB) += 47,500
   Мастер-БД:     ObligationEntity: debtor=Альфа, creditor=Бета, ₽47,500

// Теперь Альфа должна Бете ₽47,500. Вася получил баланс у Альфы.

Схема Д: Кассир Беты выдаёт деньги клиенту Альфы

Зеркальный сценарий: Вася хочет получить $500, а кассир Альфы далеко.

1. Альфа создаёт OrderEntity (type=payout, executor_broker=Бета)
   counterpart_uid: FO-7a3f8b2c4d1e (Вася)
   instrument: USD-CASH, amount: $500

2. Кассир Беты выдаёт Васе $500

3. Атомарная запись (Saga):
   Тенант Альфы:  AccountEntity (Вася, USD) -= 500
   Тенант Беты:   AccountEntity (кассир, USD) -= 500
   Мастер-БД:     ObligationEntity: debtor=Альфа, creditor=Бета, $500

// Альфа снова должна Бете. Обязательства накапливаются.

Кросс-брокерский перевод через UID

Альфа переводит деньги клиенту Беты напрямую по UID.

1. Альфа создаёт NetworkRequestEntity:
   type: send, target_broker: Бета
   counterpart_uid: FO-2b9c1d4e8f3a (клиент Беты)
   currency: USDT-TRC20, amount: 5,000

2. Бета принимает → Альфа отправляет proof (tx_hash крипто)

3. Бета подтверждает получение → 4-шаговый протокол → completed

4. Saga:
   Мастер-БД:     ObligationEntity: debtor=Бета, creditor=Альфа, 5000 USDT
   Тенант Альфы:  TransactionEntity (debit)
   Тенант Беты:   TransactionEntity (credit)

// Обратное направление: Бета должна Альфе.
// Если ранее Альфа должна Бете — обязательства частично гасятся (netting).

6 Сущности межброкерской сети

Три ключевые сущности в мастер-БД

Создано в коде

NetworkRequestEntity

idint, PK
numberNR-{6hex} unique
author_broker_idFK → BrokerEntity
target_broker_idFK (nullable = broadcast)
typeenum: send | receive
currency_codestring
amountdecimal
methodcash | crypto | bank
proof_jsonjson (nullable)
statuspending→accepted→sent→completed
obligation_idFK (при completed)
deadline_atdatetime (nullable)
Создано в коде

ObligationEntity

idint, PK
debtor_broker_idFK → BrokerEntity
creditor_broker_idFK → BrokerEntity
currency_codestring
amountdecimal (исходная)
remaining_amountdecimal (осталось)
request_idFK → NetworkRequestEntity
statusactive | settled | disputed
settled_atdatetime (nullable)
Создано в коде

ObligationSettlementEntity

idint, PK
obligation_idFK → ObligationEntity
request_idFK → NetworkRequestEntity
amountdecimal
created_atdatetime

Частичное погашение: send-request привязанный к obligation_id уменьшает remaining_amount

7 Взаимозачёт (Netting) — отложен

Логика проста, но бессмысленна при 2-5 брокерах. Включается при 10+ активных в сети.

Интерактивный пример взаимозачёта

Альфа

должна Бете
$10,000

Бета

должна Альфе
$7,000
Почему отложено? Нет смысла при 2-5 брокерах — они могут договориться лично. Netting становится критичным при 10+ брокерах, когда обязательства формируют сложный граф. Метод NetworkService::proposeNetting() создан как заглушка — активируется при достижении масштаба.
// Погашение через send-request (Схема Е, 4 шага):

Альфа должна Бете: $10K
Бета должна Альфе: $7K

→ Платформа предлагает зачёт на $7K
→ Обе стороны подтверждают (NetworkRequest type=settlement)
→ ObligationSettlementEntity: obligation_id=X, amount=$7K
→ Obligation.remaining_amount: $10K → $3K

// Остаётся: Альфа должна Бете $3K

8 SOLO / FULL — два режима базы данных

10,000 соло-брокеров не могут иметь 10,000 отдельных баз. Решение: shared sharding.

SOLO Mode

Shared DB (шардинг)

Все соло-брокеры в общей БД fastotc_shared_01. Таблицы crm_partner, crm_swap, crm_account — все имеют broker_id. Repository добавляет WHERE broker_id = ? автоматически.

+ Бесплатная регистрация
+ Мгновенный старт
- Нет выделенных ресурсов
- Нет кастомного домена
FULL Mode

Dedicated DB

Отдельная база на брокера (текущая модель). Полная изоляция, кастомный домен, поддержка команды. AppTenancyInterface::getDb() возвращает выделенное соединение.

+ Полная изоляция
+ Кастомный домен
+ Команда и роли
$ Платная подписка
Миграция SOLO → FULL (фоновая задача через InterBus):
1. Создать выделенную БД
2. Скопировать строки с broker_id = X из shared-таблиц
3. Переключить BrokerEntity.broker_mode = FULL
4. Удалить строки из shared
Без даунтайма — пользователь работает в старом режиме до завершения миграции.
// Новые поля в BrokerEntity:

broker_mode          enum: SOLO | FULL
shared_shard_id      int          // номер shared-базы (для SOLO)
referred_by_broker_id int, nullable // кто пригласил (реферал)

// Привязка к тарифам:
Starter ($0)   → SOLO  → 3 offline к/а,  unlimited online
Growth  ($29)  → SOLO  → 50 offline к/а, unlimited online
Pro     ($99)  → FULL  → unlimited + team + domain

9 Ключевые выводы

Saga, не distributed transaction

MySQL не умеет кросс-инстансные транзакции. Saga с outbox table даёт eventual consistency + компенсирующие транзакции при ошибках. Каждый шаг идемпотентен.

4-шаговый протокол

pending → accepted → sent → completed. Обе стороны подтверждают. Proof (фото, tx_hash) хранится в json. Атомарная запись в 3 базы только на финальном шаге.

Обязательства вместо мгновенных переводов

ObligationEntity накапливает долги между брокерами. Netting (взаимозачёт) отложен до масштаба 10+ брокеров. Пока — ручное погашение через send-request.

SOLO/FULL — масштаб без боли

Бесплатные брокеры не создают 10K отдельных баз. Shared sharding + автоматическая миграция при апгрейде подписки. AppTenancyInterface абстрагирует разницу.