# Синхронизация блоков и 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 `.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. Удаляются файлы `.bch` и `.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. собирается `.tmp_bch` как полный кандидат на замену основного файла; 2. пишется маленький sidecar `.write_check` с `blockNumber` и `blockHash`; 3. только после этого создаётся пустой marker `.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 работают в живом сценарии, а не только в коде.