Добавить resync блокчейна при рассинхроне
This commit is contained in:
parent
23edad416c
commit
be4f76834a
@ -86,6 +86,7 @@
|
||||
- `bad_signature`, `signature_verify_failed`
|
||||
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
||||
- `limit_exceeded`
|
||||
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||
- `internal_error`
|
||||
|
||||
|
||||
@ -74,6 +74,13 @@
|
||||
3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`;
|
||||
4. каждый скачанный блок локально применяется через существующий `AddBlock`;
|
||||
5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальный `solana_users + 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 Зачем понадобился `GetSyncUserProfile`
|
||||
|
||||
@ -161,8 +168,12 @@
|
||||
| Push новых DM партнёрам | Нужна реализация |
|
||||
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
||||
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
||||
| Разрешение рассинхрона / divergence | Нужна реализация |
|
||||
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
||||
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
||||
|
||||
Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами.
|
||||
Не реализованы ещё DM-sync, постоянные server-to-server соединения и автоматическое исправление рассинхрона цепочек.
|
||||
Не реализованы ещё DM-sync, постоянные server-to-server соединения и recovery при старте по marker-file для resync.
|
||||
|
||||
Следующие отдельные шаги после текущего этапа:
|
||||
- добавить startup recovery по marker-file для resync-цепочек;
|
||||
- вернуть обычному `AddBlock` настоящую `tmp_bch`-схему записи и recovery при резком рестарте.
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
|
||||
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
||||
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
|
||||
- `near/2026-06-26_1215_tmp_bch_для_обычного_addblock.md` - вернуть обычному `AddBlock` настоящую crash-safe схему через `.tmp_bch` и привести writer к модели startup-recovery.
|
||||
|
||||
### Среднесрочные
|
||||
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
# Вернуть настоящую tmp_bch-схему для обычного AddBlock
|
||||
|
||||
## Зачем нужна эта доработка
|
||||
|
||||
Сейчас обычный `AddBlock` пишет данные так:
|
||||
|
||||
1. в SQL-транзакции добавляет блок в БД и обновляет `blockchain_state`;
|
||||
2. делает `commit`;
|
||||
3. только после этого дописывает основной файл `<blockchainName>.bch`.
|
||||
|
||||
Это рабочая схема, но она не идеально crash-safe. Если сервер резко упадёт между `commit` БД и записью файла, можно получить состояние:
|
||||
|
||||
- в БД новый блок уже есть;
|
||||
- в `.bch` файла этого блока ещё нет.
|
||||
|
||||
При этом в проекте уже есть логика startup-recovery через `*.tmp_bch`, но текущий `BlockchainWriter` её полноценно не использует. Из-за этого writer и recovery сейчас живут в разных моделях.
|
||||
|
||||
## Что планируем сделать
|
||||
|
||||
Нужно вернуть единую и понятную схему:
|
||||
|
||||
1. обычный `AddBlock` работает через временный файл `<blockchainName>.tmp_bch`;
|
||||
2. после успешной подготовки нового содержимого выполняется атомарная подмена `tmp -> main`;
|
||||
3. `BlockchainTmpRecoveryOnStartup` и `BlockchainWriter` используют одну и ту же модель;
|
||||
4. при резкой перезагрузке сервер на старте может корректно добрать или откатить незавершённый файловый шаг.
|
||||
|
||||
## Что уже есть в коде
|
||||
|
||||
- recovery-класс: `SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java`
|
||||
- файловые helper-методы под `tmp_bch`: `SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java`
|
||||
- текущий writer: `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java`
|
||||
|
||||
## Что надо будет изменить
|
||||
|
||||
1. Переписать `BlockchainWriter`, чтобы он больше не писал сразу в основной `.bch`.
|
||||
2. Привести порядок записи и замены файла в соответствие с `BlockchainTmpRecoveryOnStartup`.
|
||||
3. Проверить сценарии:
|
||||
- обычный `AddBlock`;
|
||||
- резкий restart до commit БД;
|
||||
- резкий restart после commit БД, но до замены файла;
|
||||
- recovery на старте при наличии `*.tmp_bch`.
|
||||
|
||||
## Почему это отложено отдельно
|
||||
|
||||
Это отдельная задача от server-to-server resync. Для divergence-resync сейчас важнее сначала закончить безопасный SQL cleanup одной цепочки, а усиление crash-safety обычного `AddBlock` делать следующим самостоятельным шагом.
|
||||
@ -0,0 +1,19 @@
|
||||
# DAO очистки одной blockchain-цепочки перед полным resync
|
||||
|
||||
- Краткое описание:
|
||||
- Добавлен отдельный DAO-метод, который в одной SQL-транзакции очищает одну blockchain-цепочку перед её полной повторной загрузкой.
|
||||
- Внутри транзакции сначала уменьшаются чужие `likes_count` и `replies_count`, которые были увеличены блоками удаляемой цепочки.
|
||||
- Затем удаляются локальные записи цепочки и её derived-state.
|
||||
- Файлы `.bch` / `.tmp_bch` этот DAO не удаляет: файловый шаг будет отдельной следующей задачей.
|
||||
- DAO уже используется в periodic full-resync flow, но это ещё не было вручную проверено на прод-цикле.
|
||||
|
||||
- Что именно проверять:
|
||||
- Метод корректно компилируется и не ломает сборку.
|
||||
- Метод подключён в рабочий periodic full-resync path, но manual e2e verification ещё нужна.
|
||||
- Комментарии в коде понятны и соответствуют реальному порядку SQL-шагов.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Появилась изолированная транзакционная точка очистки одной цепочки, на которую можно безопасно опереться при следующем шаге реализации divergence-resync.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -29,6 +29,9 @@ public final class FileStoreUtil {
|
||||
/** Расширение временного файла (старое+новое). */
|
||||
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
||||
|
||||
/** Маркер того, что chain сейчас в процессе полного resync. */
|
||||
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
||||
|
||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
||||
|
||||
private final Path dataDirPath;
|
||||
@ -130,6 +133,44 @@ public final class FileStoreUtil {
|
||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
||||
}
|
||||
|
||||
/** <blockchainName>.resync_pending */
|
||||
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
|
||||
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
|
||||
}
|
||||
|
||||
public void deleteIfExists(Path path) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteBlockchainFileIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно заменить основной файл блокчейна временным:
|
||||
* <name>.tmp_bch -> <name>.bch
|
||||
@ -202,4 +243,4 @@ public final class FileStoreUtil {
|
||||
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,394 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DatabaseInitializer;
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* BlockchainResyncCleanupDAO — подготовительный "жёсткий reset" одной blockchain-цепочки
|
||||
* перед её полной повторной загрузкой от сервера-партнёра.
|
||||
*
|
||||
* Что делает этот DAO:
|
||||
* 1) в ОДНОЙ SQL-транзакции сначала аккуратно уменьшает чужие агрегаты,
|
||||
* которые были увеличены блоками удаляемой цепочки:
|
||||
* - likes_count
|
||||
* - replies_count
|
||||
* 2) затем удаляет все локальные записи самой цепочки и её производные состояния.
|
||||
*
|
||||
* Почему это вынесено в отдельный DAO-метод, а не в триггеры DELETE:
|
||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||
* SQLite не может атомарно закоммитить и SQL, и файловую систему сразу;
|
||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||
* отдельным recovery/resync-слоем после успешного commit.
|
||||
*
|
||||
* Важный смысл текущей реализации:
|
||||
* - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы;
|
||||
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
||||
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
||||
*/
|
||||
public final class BlockchainResyncCleanupDAO {
|
||||
|
||||
private static final int BLOCKCHAIN_LOGIN_SUFFIX_LEN = 4; // "-001"
|
||||
|
||||
private static volatile BlockchainResyncCleanupDAO instance;
|
||||
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private BlockchainResyncCleanupDAO() {}
|
||||
|
||||
public static BlockchainResyncCleanupDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (BlockchainResyncCleanupDAO.class) {
|
||||
if (instance == null) instance = new BlockchainResyncCleanupDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью очищает одну blockchain-цепочку и локальные derived-state, собранные из неё.
|
||||
*
|
||||
* Порядок внутри транзакции намеренно такой:
|
||||
* 1. Сначала уменьшаем чужие likes_count для тех целей, где финальное состояние
|
||||
* реакции этой цепочки было LIKE.
|
||||
* 2. Сначала уменьшаем чужие replies_count для reply-блоков этой цепочки.
|
||||
* 3. После этого удаляем локальные derived-state самой цепочки.
|
||||
* 4. В конце удаляем blocks и blockchain_state.
|
||||
*
|
||||
* Это правильно потому, что агрегаты (`message_stats`) должны видеть исходные blocks
|
||||
* и reactions_state на момент пересчёта. Если удалить blocks раньше, мы потеряем
|
||||
* источник правды для корректного уменьшения счётчиков.
|
||||
*
|
||||
* Метод идемпотентен по смыслу:
|
||||
* - если часть данных уже удалена раньше, повторный вызов просто удалит "0 строк";
|
||||
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||
*
|
||||
* Отдельно важно:
|
||||
* - здесь НЕТ удаления .bch/.tmp_bch;
|
||||
* - здесь НЕТ повторной загрузки цепочки;
|
||||
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||
*/
|
||||
public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException {
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("blockchainName is blank");
|
||||
}
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
String login = resolveLoginForCleanup(c, blockchainName);
|
||||
|
||||
int likesAdjusted = decreaseForeignLikesCount(c, blockchainName);
|
||||
int repliesAdjusted = decreaseForeignRepliesCount(c, blockchainName);
|
||||
|
||||
int deletedMessageStats = deleteMessageStatsForOwnTargets(c, blockchainName);
|
||||
int deletedReactionsState = deleteReactionsStateForActorChain(c, blockchainName);
|
||||
int deletedConnectionsState = deleteConnectionsStateForLogin(c, login);
|
||||
int deletedUsersParams = deleteUsersParamsForLogin(c, login);
|
||||
int deletedChannelNames = deleteChannelNamesForOwnerChain(c, blockchainName);
|
||||
int deletedChat200State = deleteChat200StateForOwnerChain(c, blockchainName);
|
||||
int deletedChat200Members = deleteChat200MembersForOwnerChain(c, blockchainName);
|
||||
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||
|
||||
c.commit();
|
||||
|
||||
return new CleanupResult(
|
||||
login,
|
||||
likesAdjusted,
|
||||
repliesAdjusted,
|
||||
deletedMessageStats,
|
||||
deletedReactionsState,
|
||||
deletedConnectionsState,
|
||||
deletedUsersParams,
|
||||
deletedChannelNames,
|
||||
deletedChat200State,
|
||||
deletedChat200Members,
|
||||
deletedBlocks,
|
||||
deletedBlockchainState
|
||||
);
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||
throw new SQLException("Не удалось очистить blockchain для полного resync: " + blockchainName, e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login
|
||||
FROM blockchain_state
|
||||
WHERE blockchain_name = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
String login = rs.getString("login");
|
||||
if (login != null && !login.isBlank()) {
|
||||
return login;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String loginFromName = loginFromBlockchainName(blockchainName);
|
||||
if (loginFromName == null || loginFromName.isBlank()) {
|
||||
throw new IllegalArgumentException("Cannot derive login from blockchainName: " + blockchainName);
|
||||
}
|
||||
return loginFromName;
|
||||
}
|
||||
|
||||
/**
|
||||
* DAO остаётся в модуле БД и не тянет зависимость на blockchain-utils модуль.
|
||||
* Поэтому здесь локально повторяем минимальное правило имени chain:
|
||||
* login + "-NNN".
|
||||
*/
|
||||
private String loginFromBlockchainName(String blockchainName) {
|
||||
if (blockchainName == null) return null;
|
||||
|
||||
String s = blockchainName.trim();
|
||||
if (s.length() <= BLOCKCHAIN_LOGIN_SUFFIX_LEN) return null;
|
||||
|
||||
int dashPos = s.length() - BLOCKCHAIN_LOGIN_SUFFIX_LEN;
|
||||
if (s.charAt(dashPos) != '-') return null;
|
||||
|
||||
for (int i = dashPos + 1; i < s.length(); i++) {
|
||||
char ch = s.charAt(i);
|
||||
if (ch < '0' || ch > '9') return null;
|
||||
}
|
||||
return s.substring(0, dashPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уменьшаем likes_count только для ЧУЖИХ целей.
|
||||
*
|
||||
* Логика:
|
||||
* - если у удаляемой цепочки финальное состояние реакции на цель = LIKE,
|
||||
* значит при полном удалении цепочки этот активный лайк исчезает;
|
||||
* - значит у message_stats этой чужой цели нужно сделать -1;
|
||||
* - для целей внутри этой же chain этого делать не нужно, потому что сами цели
|
||||
* тоже будут удалены вместе с цепочкой.
|
||||
*/
|
||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
0,
|
||||
likes_count - (
|
||||
SELECT COUNT(*)
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
AND rs.last_sub_type = ?
|
||||
AND rs.to_login = message_stats.to_login
|
||||
AND rs.to_bch_name = message_stats.to_bch_name
|
||||
AND rs.to_block_number = message_stats.to_block_number
|
||||
AND rs.to_block_hash = message_stats.to_block_hash
|
||||
AND rs.to_bch_name <> ?
|
||||
)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
AND rs.last_sub_type = ?
|
||||
AND rs.to_login = message_stats.to_login
|
||||
AND rs.to_bch_name = message_stats.to_bch_name
|
||||
AND rs.to_block_number = message_stats.to_block_number
|
||||
AND rs.to_block_hash = message_stats.to_block_hash
|
||||
AND rs.to_bch_name <> ?
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setString(i++, blockchainName);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уменьшаем replies_count только для ЧУЖИХ целей.
|
||||
*
|
||||
* Если reply этой цепочки ссылался на сообщение из другой цепочки,
|
||||
* значит после удаления blocks этой цепочки чужой replies_count должен уменьшиться.
|
||||
* Reply на собственные сообщения здесь игнорируем: целевая цепочка тоже будет удалена.
|
||||
*/
|
||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET replies_count = MAX(
|
||||
0,
|
||||
replies_count - COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
AND b.msg_sub_type = ?
|
||||
AND b.to_login = message_stats.to_login
|
||||
AND b.to_bch_name = message_stats.to_bch_name
|
||||
AND b.to_block_number = message_stats.to_block_number
|
||||
AND b.to_block_hash = message_stats.to_block_hash
|
||||
AND b.to_bch_name <> ?
|
||||
), 0)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
AND b.msg_sub_type = ?
|
||||
AND b.to_login = message_stats.to_login
|
||||
AND b.to_bch_name = message_stats.to_bch_name
|
||||
AND b.to_block_number = message_stats.to_block_number
|
||||
AND b.to_block_hash = message_stats.to_block_hash
|
||||
AND b.to_bch_name <> ?
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
||||
ps.setString(i++, blockchainName);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Статистика сообщений самой удаляемой цепочки после reset не нужна,
|
||||
* потому что её цели исчезают вместе с chain source data.
|
||||
*/
|
||||
private int deleteMessageStatsForOwnTargets(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM message_stats
|
||||
WHERE to_bch_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
/**
|
||||
* reactions_state хранит финальное состояние реакций АКТОРА.
|
||||
* После удаления всей цепочки актор этой цепочки исчезает, поэтому
|
||||
* достаточно удалить все строки по from_bch_name.
|
||||
*/
|
||||
private int deleteReactionsStateForActorChain(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM reactions_state
|
||||
WHERE from_bch_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
/**
|
||||
* connections_state — текущее состояние связей, выставленных этим login.
|
||||
* Чистим по владельцу состояния.
|
||||
*/
|
||||
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM connections_state
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
""", login);
|
||||
}
|
||||
|
||||
/**
|
||||
* users_params — актуальные параметры, собранные из блоков пользователя.
|
||||
*/
|
||||
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM users_params
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
""", login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Каналы принадлежат owner_bch_name.
|
||||
*/
|
||||
private int deleteChannelNamesForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM channel_names_state
|
||||
WHERE owner_bch_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
private int deleteChat200StateForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM chat200_state
|
||||
WHERE owner_bch_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
private int deleteChat200MembersForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM chat200_members_state
|
||||
WHERE owner_bch_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
/**
|
||||
* blocks удаляем в конце, потому что до этого шага они нужны как источник правды
|
||||
* для уменьшения replies_count.
|
||||
*/
|
||||
private int deleteBlocksForChain(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM blocks
|
||||
WHERE bch_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
/**
|
||||
* blockchain_state удаляем после blocks, чтобы не нарушать FK-связь blocks -> blockchain_state.
|
||||
*/
|
||||
private int deleteBlockchainStateForChain(Connection c, String blockchainName) throws SQLException {
|
||||
return executeDelete(c, """
|
||||
DELETE FROM blockchain_state
|
||||
WHERE blockchain_name = ?
|
||||
""", blockchainName);
|
||||
}
|
||||
|
||||
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, value);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Технический результат cleanup-операции.
|
||||
* Нужен для будущего логирования и ручной диагностики resync-flow.
|
||||
*/
|
||||
public record CleanupResult(
|
||||
String login,
|
||||
int likesAdjustedRows,
|
||||
int repliesAdjustedRows,
|
||||
int deletedMessageStatsRows,
|
||||
int deletedReactionsStateRows,
|
||||
int deletedConnectionsStateRows,
|
||||
int deletedUsersParamsRows,
|
||||
int deletedChannelNamesRows,
|
||||
int deletedChat200StateRows,
|
||||
int deletedChat200MembersRows,
|
||||
int deletedBlocksRows,
|
||||
int deletedBlockchainStateRows
|
||||
) {}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import server.sync.BlockchainResyncGuard;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
@ -70,6 +71,21 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq;
|
||||
|
||||
String blockchainName = req.getBlockchainName();
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
return error(req, WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, "");
|
||||
}
|
||||
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||
BlockchainStateEntry currentState = null;
|
||||
try {
|
||||
currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||
return error(req, 423, "chain_resync_in_progress",
|
||||
lastNum,
|
||||
lastHash);
|
||||
}
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
try {
|
||||
@ -126,7 +142,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
private static String humanMessage(String code) {
|
||||
if (code == null) return "Ошибка добавления блока"; return switch (code) {
|
||||
if (code == null) return "Ошибка добавления блока";
|
||||
return switch (code) {
|
||||
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
||||
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
||||
case "db_error" -> "Ошибка базы данных";
|
||||
@ -150,6 +167,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
default -> "Ошибка: " + code;
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package server.sync;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* In-memory guard для цепочек, которые сейчас находятся в полном resync.
|
||||
*
|
||||
* Задача guard-а:
|
||||
* - не давать обычному AddBlock писать в цепочку, пока она пересобирается;
|
||||
* - позволять внутреннему resync-потоку безопасно вызывать тот же AddBlock-путь
|
||||
* через thread-local bypass.
|
||||
*/
|
||||
public final class BlockchainResyncGuard {
|
||||
|
||||
private static final Set<String> ACTIVE = ConcurrentHashMap.newKeySet();
|
||||
private static final ThreadLocal<Set<String>> BYPASS = ThreadLocal.withInitial(HashSet::new);
|
||||
|
||||
private BlockchainResyncGuard() {}
|
||||
|
||||
public static boolean tryBegin(String blockchainName) {
|
||||
String key = normalize(blockchainName);
|
||||
if (key == null) return false;
|
||||
return ACTIVE.add(key);
|
||||
}
|
||||
|
||||
public static void end(String blockchainName) {
|
||||
String key = normalize(blockchainName);
|
||||
if (key == null) return;
|
||||
ACTIVE.remove(key);
|
||||
}
|
||||
|
||||
public static boolean isBlockedForExternalAddBlock(String blockchainName) {
|
||||
String key = normalize(blockchainName);
|
||||
if (key == null) return false;
|
||||
return ACTIVE.contains(key) && !isBypassed(key);
|
||||
}
|
||||
|
||||
public static <T> T withBypass(String blockchainName, ThrowingSupplier<T> supplier) throws Exception {
|
||||
String key = normalize(blockchainName);
|
||||
if (key == null) {
|
||||
return supplier.get();
|
||||
}
|
||||
|
||||
Set<String> bypassSet = BYPASS.get();
|
||||
bypassSet.add(key);
|
||||
try {
|
||||
return supplier.get();
|
||||
} finally {
|
||||
bypassSet.remove(key);
|
||||
if (bypassSet.isEmpty()) {
|
||||
BYPASS.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void withBypass(String blockchainName, ThrowingRunnable runnable) throws Exception {
|
||||
withBypass(blockchainName, () -> {
|
||||
runnable.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isBypassed(String blockchainName) {
|
||||
return BYPASS.get().contains(blockchainName);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ThrowingSupplier<T> {
|
||||
T get() throws Exception;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ThrowingRunnable {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@ -6,15 +6,20 @@ import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import shine.db.dao.BlockchainResyncCleanupDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.SyncServersDAO;
|
||||
import shine.db.dao.UserCreateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import server.sync.BlockchainResyncGuard;
|
||||
import utils.files.FileStoreUtil;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Executors;
|
||||
@ -25,8 +30,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Плановый межсерверный sync блокчейнов.
|
||||
* Сейчас реализует только догоняющую синхронизацию отсутствующего хвоста.
|
||||
* Случай рассинхрона цепочек пока только логируется и пропускается.
|
||||
* Сейчас реализует:
|
||||
* - догоняющую синхронизацию отсутствующего хвоста;
|
||||
* - базовый full-resync при divergence, если удалённая цепочка сильнее.
|
||||
*/
|
||||
public final class PeriodicBlockchainSyncService {
|
||||
|
||||
@ -47,6 +53,8 @@ public final class PeriodicBlockchainSyncService {
|
||||
private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance();
|
||||
private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance();
|
||||
private static final UserCreateDAO USER_CREATE_DAO = UserCreateDAO.getInstance();
|
||||
private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance();
|
||||
private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance();
|
||||
private static final String CONFIG_IMPORT_PROFILE_FROM_PARTNER = "sync.importUserProfileFromPartner.enabled";
|
||||
|
||||
private PeriodicBlockchainSyncService() {}
|
||||
@ -121,14 +129,25 @@ public final class PeriodicBlockchainSyncService {
|
||||
if (localHash.equalsIgnoreCase(remoteHead.lastBlockHash())) {
|
||||
continue;
|
||||
}
|
||||
log.warn("Periodic blockchain sync: divergence detected but not implemented yet. partner={} blockchainName={} localLast={} localHash={} remoteHash={} localSize={} remoteSize={}",
|
||||
partnerLogin,
|
||||
remoteHead.blockchainName(),
|
||||
localLast,
|
||||
localHash,
|
||||
remoteHead.lastBlockHash(),
|
||||
localState.getFileSizeBytes(),
|
||||
remoteHead.fileSizeBytes());
|
||||
if (isRemoteStronger(localState, remoteHead)) {
|
||||
log.warn("Periodic blockchain sync: divergence detected, remote chain is stronger, starting full resync. partner={} blockchainName={} localLast={} localHash={} remoteHash={} localSize={} remoteSize={}",
|
||||
partnerLogin,
|
||||
remoteHead.blockchainName(),
|
||||
localLast,
|
||||
localHash,
|
||||
remoteHead.lastBlockHash(),
|
||||
localState.getFileSizeBytes(),
|
||||
remoteHead.fileSizeBytes());
|
||||
resyncFromScratch(partner, remoteHead);
|
||||
} else {
|
||||
log.info("Periodic blockchain sync skipped: local chain is stronger or equal. partner={} blockchainName={} localLast={} remoteLast={} localSize={} remoteSize={}",
|
||||
partnerLogin,
|
||||
remoteHead.blockchainName(),
|
||||
localLast,
|
||||
remoteHead.lastBlockNumber(),
|
||||
localState.getFileSizeBytes(),
|
||||
remoteHead.fileSizeBytes());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -163,8 +182,9 @@ public final class PeriodicBlockchainSyncService {
|
||||
LocalAddBlockApplyResult result = applyBlockLocally(remoteBlock, blockNumber == 0 ? "" : localHash);
|
||||
if (!result.ok()) {
|
||||
if ("bad_prev_hash".equalsIgnoreCase(result.code()) || "bad_block_number".equalsIgnoreCase(result.code())) {
|
||||
log.warn("Periodic blockchain sync: divergence detected during replay, but reconciliation is not implemented yet. partner={} blockchainName={} blockNumber={} code={}",
|
||||
log.warn("Periodic blockchain sync: divergence detected during replay, starting full resync. partner={} blockchainName={} blockNumber={} code={}",
|
||||
partnerLogin, remoteHead.blockchainName(), blockNumber, result.code());
|
||||
resyncFromScratch(partner, remoteHead);
|
||||
} else {
|
||||
log.warn("Periodic blockchain sync: local AddBlock rejected remote block. partner={} blockchainName={} blockNumber={} code={} message={}",
|
||||
partnerLogin, remoteHead.blockchainName(), blockNumber, result.code(), result.message());
|
||||
@ -179,6 +199,132 @@ public final class PeriodicBlockchainSyncService {
|
||||
partnerLogin, remoteHead.blockchainName(), fromBlockNumber, remoteHead.lastBlockNumber());
|
||||
}
|
||||
|
||||
private static void resyncFromScratch(
|
||||
SyncServerEntry partner,
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead
|
||||
) throws Exception {
|
||||
if (partner == null || remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String blockchainName = remoteHead.blockchainName();
|
||||
String partnerLogin = normalize(partner.getLogin());
|
||||
|
||||
if (!BlockchainResyncGuard.tryBegin(blockchainName)) {
|
||||
log.warn("Blockchain resync skipped: already in progress for blockchainName={}", blockchainName);
|
||||
return;
|
||||
}
|
||||
|
||||
String markerContent = """
|
||||
blockchainName=%s
|
||||
partnerLogin=%s
|
||||
partnerAddress=%s
|
||||
remoteLastBlockNumber=%d
|
||||
remoteLastBlockHash=%s
|
||||
remoteFileSizeBytes=%d
|
||||
startedAtMs=%d
|
||||
""".formatted(
|
||||
blockchainName,
|
||||
partnerLogin == null ? "" : partnerLogin,
|
||||
partner.getServerAddress() == null ? "" : partner.getServerAddress(),
|
||||
remoteHead.lastBlockNumber(),
|
||||
remoteHead.lastBlockHash(),
|
||||
remoteHead.fileSizeBytes(),
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
|
||||
FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent);
|
||||
|
||||
ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName);
|
||||
chainLock.lock();
|
||||
try {
|
||||
BlockchainResyncCleanupDAO.CleanupResult cleanup =
|
||||
RESYNC_CLEANUP_DAO.cleanupBlockchainForFullResync(blockchainName);
|
||||
log.info("Blockchain resync cleanup finished: blockchainName={} login={} likesAdjusted={} repliesAdjusted={} deletedBlocks={} deletedState={}",
|
||||
blockchainName,
|
||||
cleanup.login(),
|
||||
cleanup.likesAdjustedRows(),
|
||||
cleanup.repliesAdjustedRows(),
|
||||
cleanup.deletedBlocksRows(),
|
||||
cleanup.deletedBlockchainStateRows());
|
||||
|
||||
FILE_STORE.deleteBlockchainFileIfExists(blockchainName);
|
||||
FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName);
|
||||
|
||||
if (!ensureLocalChainExists(partner, blockchainName)) {
|
||||
log.warn("Blockchain resync aborted: failed to recreate local chain state. partner={} blockchainName={}",
|
||||
partnerLogin, blockchainName);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean replayOk = replayRemoteChainFromStart(partner, remoteHead);
|
||||
if (replayOk) {
|
||||
log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}",
|
||||
partnerLogin, blockchainName, remoteHead.lastBlockNumber());
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName);
|
||||
} catch (Exception e) {
|
||||
log.warn("Blockchain resync: failed to delete marker file blockchainName={}", blockchainName, e);
|
||||
}
|
||||
chainLock.unlock();
|
||||
BlockchainResyncGuard.end(blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean replayRemoteChainFromStart(
|
||||
SyncServerEntry partner,
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead
|
||||
) throws Exception {
|
||||
String blockchainName = remoteHead.blockchainName();
|
||||
String partnerLogin = normalize(partner.getLogin());
|
||||
|
||||
return BlockchainResyncGuard.withBypass(blockchainName, () -> {
|
||||
String localPrevHash = "";
|
||||
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
|
||||
REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber);
|
||||
if (remoteBlock == null) {
|
||||
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
|
||||
partnerLogin, blockchainName, blockNumber);
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalAddBlockApplyResult result = applyBlockLocally(remoteBlock, localPrevHash);
|
||||
if (!result.ok()) {
|
||||
log.warn("Blockchain resync: AddBlock rejected replay block. partner={} blockchainName={} blockNumber={} code={} message={}",
|
||||
partnerLogin, blockchainName, blockNumber, result.code(), result.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
localPrevHash = result.serverLastHash();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isRemoteStronger(BlockchainStateEntry localState,
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead) {
|
||||
if (localState == null || remoteHead == null) return false;
|
||||
|
||||
int localLast = localState.getLastBlockNumber();
|
||||
int remoteLast = remoteHead.lastBlockNumber();
|
||||
if (remoteLast != localLast) {
|
||||
return remoteLast > localLast;
|
||||
}
|
||||
|
||||
long localSize = localState.getFileSizeBytes();
|
||||
long remoteSize = remoteHead.fileSizeBytes();
|
||||
if (remoteSize != localSize) {
|
||||
return remoteSize > localSize;
|
||||
}
|
||||
|
||||
String localHash = toHex32(localState.getLastBlockHash()).toLowerCase(Locale.ROOT);
|
||||
String remoteHash = normalizeHex64(remoteHead.lastBlockHash());
|
||||
return remoteHash.compareTo(localHash) > 0;
|
||||
}
|
||||
|
||||
private static LocalAddBlockApplyResult applyBlockLocally(
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock,
|
||||
String prevHash
|
||||
@ -273,6 +419,12 @@ public final class PeriodicBlockchainSyncService {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String normalizeHex64(String value) {
|
||||
if (value == null) return "";
|
||||
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||
return s.length() == 64 ? s : "";
|
||||
}
|
||||
|
||||
private record LocalAddBlockApplyResult(
|
||||
boolean ok,
|
||||
String code,
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.273
|
||||
server.version=1.2.253
|
||||
client.version=1.2.274
|
||||
server.version=1.2.254
|
||||
|
||||
Loading…
Reference in New Issue
Block a user