Files
SHiNE-server/docs/Blockchain/sync-between-servers.md
T
AidarKC 3a5851939e Починить межсерверную репликацию личных сообщений
Репликация личных сообщений между серверами теперь работает корректно.
2026-08-25 19:31:38 +04:00

22 KiB
Raw Blame History

Синхронизация блоков и DM между серверами SHiNE

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

1. Зачем нужна синхронизация

Пользователи SHiNE могут быть «приписаны» к разным серверам. Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):

  1. Сервер X принимает сообщение;
  2. Сервер X должен переслать DM-блок серверу Y;
  3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.

Аналогично, блоки пользовательского блокчейна (записи AddBlock) должны синхронизироваться, чтобы любой партнёрский сервер мог отдать полную историю пользователя.

2. Список серверов синхронизации (sync_servers)

Каждый сервер регистрирует в своей Solana PDA список sync_servers — логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.

Важно: в текущей архитектуре у пользователя одновременно может быть не более двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики synced и пользовательских курсоров.

  • Список хранится в блоке ServerProfileBlock внутри user_pda сервера.
  • Адрес каждого партнёрского сервера читается из его PDA на Solana.
  • Синхронизация двусторонняя: оба сервера должны иметь друг друга в sync_servers.

3. Что синхронизируется

3.1 Личные сообщения (DM)

  • Все DM-блоки форматов типов 1/2 (текст) и 3/4 (read-receipt).
  • Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
  • Сервер-получатель: сохраняет входящий блок в signed_messages, затем доставляет его активным сессиям.
  • Дедупликация по уникальному message_key = from|to|timeMs|nonce|type.
  • Репликация между двумя access-серверами пользователя работает через dm_sync_outbox.synced, без постоянного time-cursor.
  • Полная актуальная схема: docs/Personal_Messages/Доставка_и_синхронизация_DM.md.

3.2 Блоки пользовательского блокчейна

  • Все блоки AddBlock пользователей, зарегистрированных на сервере или синхронизирующихся через него.
  • Синхронизируются в обе стороны между всеми партнёрами из sync_servers.
  • Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
  • Дедупликация по глобальному номеру блока и хэшу.

3.3 Пользовательские настройки

  • Отдельная таблица user_settings.
  • Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
  • Для текущего UI-кейса хранится setting_type = 1 и setting_key = ownerBlockchainName/channelName.
  • Синхронизация идёт с учётом time_ms и флага synced.

4. Текущая реализованная схема

На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.

4.1 Что уже сделано

  1. При старте сервер читает свой server.SHiNE.login.
  2. По этому логину он загружает из Solana свою server PDA.
  3. Из неё вытаскивает список sync_servers.
  4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально:
    • login
    • server_address
  5. После этого:
    • новые локальные AddBlock рассылаются партнёрам в фоне;
    • при старте запускается periodic sync;
    • periodic sync повторяется каждые 12 часов после старта.

4.2 Какие server-to-server API уже используются

  • ListBlockchainHeads — список heads всех локальных цепочек партнёра;
  • GetBlockchainBlock — чтение одного конкретного блока партнёра;
  • GetSyncUserProfile — минимальный профиль пользователя для локального создания runtime-проекции пользователя и blockchain_state без обращения в Solana RPC.

4.3 Как сейчас работает periodic sync

Для каждого сервера из локальной таблицы sync_servers:

  1. запрашивается ListBlockchainHeads;
  2. для каждой удалённой цепочки сравниваются:
    • lastBlockNumber
    • lastBlockHash
    • локальное состояние;
  3. если локальная цепочка слабее, сервер по одному блоку вызывает GetBlockchainBlock;
  4. каждый скачанный блок локально применяется через существующий AddBlock;
  5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и blockchain_state.
  6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync:
    • цепочка помечается in-memory как resync in progress;
    • создаётся marker-file в data/;
    • в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики;
    • удаляются .bch и .tmp_bch;
    • цепочка подтягивается заново с 0 через GetBlockchainBlock.
    • обычный AddBlock на эту цепочку в этот момент возвращает chain_resync_in_progress.

4.4 Как именно работает full resync

Full resync запускается только тогда, когда:

  • локальная chain отстаёт и обычная докачка хвоста упирается в bad_prev_hash или bad_block_number;
  • либо высота цепочек одинаковая, но удалённая версия сильнее по правилу:
    • lastBlockNumber;
    • fileSizeBytes;
    • lastBlockHash.

Порядок действий:

  1. Ставится in-memory guard на blockchainName.
  2. Создаётся marker-file <blockchainName>.resync_pending.
  3. Обычный AddBlock на эту chain временно получает chain_resync_in_progress.
  4. Вызывается атомарный SQL cleanup одной chain:
    • уменьшаются чужие likes_count и replies_count;
    • удаляются локальные derived-state записи этой chain;
    • удаляются blocks и blockchain_state этой chain.
  5. Удаляются файлы <blockchainName>.bch и <blockchainName>.tmp_bch.
  6. Локальная chain создаётся заново через GetSyncUserProfile или через Solana import, если sync.importUserProfileFromPartner.enabled=false.
  7. Chain replay-ится с 0 через GetBlockchainBlock.
  8. Если всё прошло успешно, marker-file удаляется.
  9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте.

Важно:

  • full resync не делает умный rollback по одному блоку;
  • full resync не трогает DM-таблицы и current users слой;
  • висячие cross-chain ссылки считаются допустимым поведением системы.

4.5 Как работает обычный AddBlock и его recovery

Обычная запись блока теперь тоже идёт через временные артефакты:

  1. собирается <blockchainName>.tmp_bch как полный кандидат на замену основного файла;
  2. пишется маленький sidecar <blockchainName>.write_check с blockNumber и blockHash;
  3. только после этого создаётся пустой marker <blockchainName>.write_pending;
  4. выполняется SQL-транзакция;
  5. после commit tmp атомарно ставится на место основного .bch;
  6. marker и sidecar удаляются.

На старте BlockchainTmpRecoveryOnStartup смотрит именно на эту пару:

  • если write_pending есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы;
  • если write_pending нет, а tmp_bch или write_check остались, это мусор и он удаляется;
  • resync_pending сюда не относится, это отдельный recovery-поток.

4.6 Startup recovery по marker-file

При старте сервер идёт в таком порядке:

  1. BlockchainTmpRecoveryOnStartup для *.write_pending и orphan *.tmp_bch / *.write_check;
  2. BlockchainResyncRecoveryOnStartup для *.resync_pending;
  3. только потом поднимается обычный сервер и запускается PeriodicBlockchainSyncService.

Если marker-file существует:

  • сервер не должен начинать обычную работу поверх этой chain;
  • recovery снова выполняет cleanup и replay с нуля;
  • если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.

4.7 Зачем понадобился GetSyncUserProfile

Изначально подготовка локальной цепочки делалась через Solana:

  • из blockchainName извлекался login;
  • сервер вызывал import пользователя из Solana PDA;
  • по данным PDA локально создавались runtime-проекция пользователя и blockchain_state.

На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать HTTP 429.

Поэтому добавлен отдельный обходной режим:

  • настройка sync.importUserProfileFromPartner.enabled=true
  • в этом режиме сервер не ходит в Solana RPC для создания локальной цепочки во время sync;
  • вместо этого он запрашивает у сервера-партнёра GetSyncUserProfile и создаёт локальную запись по данным партнёра.
  • если локальная runtime-проекция пользователя уже существует, sync восстанавливает только blockchain_state и не трогает user-layer.

Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.

4.8 Что делает настройка sync.importUserProfileFromPartner.enabled

  • false — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
  • true — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server GetSyncUserProfile.

Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.

5. Реализованный постоянный server-to-server транспорт

Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает единый постоянный WSS-транспорт, через который выполняются уже существующие операции.

5.1 Межсерверное соединение

  • Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
  • Адрес партнёра определяется по server_address из его Solana PDA.
  • После подключения отправляется ServerHello с serverLogin, версией протокола и capabilities.
  • На текущем этапе serverLogin принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
  • При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
  • После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
  • Один физический канал переиспользуют DM, настройки и blockchain.

5.2 Доставка новых данных (push)

  • При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам.
  • Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
  • DM использует отдельное расписание, описанное в docs/Personal_Messages/Доставка_и_синхронизация_DM.md.

5.3 Начальная синхронизация (backfill)

  • При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
  • Сервер с более полной историей досылает недостающее партнёру.
  • DM time-cursor удалён: новый peer получает историю после сброса dm_sync_outbox.synced=false.

5.4 Разрешение конфликтов

  • Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. Конфликтующие ветки (fork) разрешаются по правилам AddBlock (см. docs/Blockchain/README.md).
  • DM: конфликтов нет, message_key уникален.

6. Маршрутизация DM между серверами

При отправке DM от пользователя A к пользователю B:

  1. Клиент A отправляет пару блоков на свой сервер X.
  2. Сервер X валидирует и локально сохраняет пару.
  3. До ответа клиенту X параллельно отправляет входящую копию максимум двум актуальным access_servers B.
  4. ACK хотя бы одного маршрута B означает терминальный delivered; второй сервер B догоняется собственной DM-синхронизацией.
  5. После первой попытки X передаёт полную пару второму access-серверу A старым ReceiveOutcomingMessage, без delivery-state.
  6. При нулевой доставке выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. Перед тремя последними X спрашивает peer A через GetDmDeliveryStatus(messageKey).
  7. После неудачной попытки через час ставится терминальный failed.

Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.

7. Безопасность

  • Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
  • Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами.
  • При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
  • Межсерверная авторизация DM-операций пока отложена; sourceServerLogin временно считается доверенным.

8. Статус реализации

Компонент Статус
Регистрация серверной PDA в Solana Реализовано
Чтение sync_servers из PDA Реализовано
Локальная таблица sync_servers Реализовано
Публичный ListBlockchainHeads Реализовано
Публичный GetBlockchainBlock Реализовано
Публичный GetSyncUserProfile Реализовано
Плановый blockchain sync при старте + каждые 12 часов Реализовано
Обход Solana RPC через sync.importUserProfileFromPartner.enabled Реализовано
Обычный AddBlock через tmp_bch/write_check/write_pending Реализовано
Межсерверный постоянный WebSocket-канал Реализован общий ServerConnectionPool
Асинхронная доставка DM на access-серверы получателя Реализовано
Retry DM до 1 часа + UI-state Реализовано
Репликация DM на второй access-сервер по synced Реализовано
Read-only GetDmDeliveryStatus Реализовано
Push блоков блокчейна партнёрам Выполняется через постоянный WSS-пул
Periodic backfill отсутствующего хвоста Реализовано
Разрешение рассинхрона / divergence Реализована базовая full-resync схема во время periodic sync
Startup recovery по *.resync_pending marker-file Реализовано
Маршрутизация DM через один/два access_servers Реализовано
Криптографическая server-to-server авторизация DM Нужна реализация

Текущая версия сервера использует постоянный WSS-пул для существующих server-to-server JSON-операций. Отдельной будущей задачей остаётся криптографическая авторизация server-to-server вызовов.

Следующие отдельные шаги после текущего этапа:

  • отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.

8.1 Практическая проверка на тестовом сервере

Проверка на server2.shineup.me показала, что текущая схема действительно поднимает цепочку при старте:

  • после рестарта сервер сначала проходит BlockchainTmpRecovery;
  • затем обрабатывает BlockchainResyncRecovery;
  • после этого сам догружает цепочку aidartest-001 с shineup.me;
  • итоговое состояние на тестовом сервере:
    • blockchain_state.last_block_number = 13
    • blocks по aidartest-001 = 14 записей

Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде.