Вернуть crash-safe запись AddBlock через tmp_bch
This commit is contained in:
parent
1ced351ea2
commit
71fdee0cfd
@ -1,5 +1,13 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 2026-06-26 17:03:22 +0400
|
||||||
|
- Базовый коммит-ориентир: `TBD`.
|
||||||
|
- Обычный `AddBlock` переведён на crash-safe схему через временный кандидат `<blockchainName>.tmp_bch`, sidecar `<blockchainName>.write_check` и marker `<blockchainName>.write_pending`.
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` теперь разбирает marker-driven recovery для обычной записи блока:
|
||||||
|
- если marker есть, recovery либо завершает swap tmp -> main, либо удаляет мусор;
|
||||||
|
- если marker нет, временные артефакты считаются мусором и удаляются.
|
||||||
|
- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлено описание обычного `AddBlock` recovery и разделение между `write_pending` и `resync_pending`.
|
||||||
|
|
||||||
## 2026-05-24 11:40:00 +0300
|
## 2026-05-24 11:40:00 +0300
|
||||||
- Базовый коммит-ориентир: `abdce05`.
|
- Базовый коммит-ориентир: `abdce05`.
|
||||||
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
|
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
|
||||||
|
|||||||
@ -30,4 +30,5 @@
|
|||||||
|
|
||||||
## Обязательное сопровождение
|
## Обязательное сопровождение
|
||||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
||||||
|
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
|
||||||
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||||
|
|||||||
@ -113,11 +113,28 @@ Full resync запускается только тогда, когда:
|
|||||||
- full resync не трогает DM-таблицы и `solana_users`;
|
- full resync не трогает DM-таблицы и `solana_users`;
|
||||||
- висячие cross-chain ссылки считаются допустимым поведением системы.
|
- висячие cross-chain ссылки считаются допустимым поведением системы.
|
||||||
|
|
||||||
### 4.5 Startup recovery по marker-file
|
### 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` для `*.tmp_bch`;
|
1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`;
|
||||||
2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`;
|
2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`;
|
||||||
3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`.
|
3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`.
|
||||||
|
|
||||||
@ -127,7 +144,7 @@ Full resync запускается только тогда, когда:
|
|||||||
- recovery снова выполняет cleanup и replay с нуля;
|
- recovery снова выполняет cleanup и replay с нуля;
|
||||||
- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.
|
- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.
|
||||||
|
|
||||||
### 4.6 Зачем понадобился `GetSyncUserProfile`
|
### 4.7 Зачем понадобился `GetSyncUserProfile`
|
||||||
|
|
||||||
Изначально подготовка локальной цепочки делалась через Solana:
|
Изначально подготовка локальной цепочки делалась через Solana:
|
||||||
|
|
||||||
@ -145,7 +162,7 @@ Full resync запускается только тогда, когда:
|
|||||||
|
|
||||||
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
|
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
|
||||||
|
|
||||||
### 4.7 Что делает настройка `sync.importUserProfileFromPartner.enabled`
|
### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled`
|
||||||
|
|
||||||
- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
|
- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
|
||||||
- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`.
|
- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`.
|
||||||
@ -209,6 +226,7 @@ Full resync запускается только тогда, когда:
|
|||||||
| Публичный `GetSyncUserProfile` | ✅ Реализовано |
|
| Публичный `GetSyncUserProfile` | ✅ Реализовано |
|
||||||
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
|
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
|
||||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||||
|
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||||
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
|
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
|
||||||
| Push новых DM партнёрам | Нужна реализация |
|
| Push новых DM партнёрам | Нужна реализация |
|
||||||
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
||||||
@ -221,5 +239,4 @@ Full resync запускается только тогда, когда:
|
|||||||
Не реализованы ещё DM-sync и постоянные server-to-server соединения.
|
Не реализованы ещё DM-sync и постоянные server-to-server соединения.
|
||||||
|
|
||||||
Следующие отдельные шаги после текущего этапа:
|
Следующие отдельные шаги после текущего этапа:
|
||||||
- вернуть обычному `AddBlock` настоящую `tmp_bch`-схему записи и recovery при резком рестарте.
|
|
||||||
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
||||||
|
|||||||
@ -30,7 +30,6 @@
|
|||||||
|
|
||||||
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
||||||
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
|
- `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.
|
|
||||||
|
|
||||||
### Среднесрочные
|
### Среднесрочные
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
# Вернуть настоящую 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,47 @@
|
|||||||
|
# Crash-safe запись обычного `AddBlock` через `tmp_bch`
|
||||||
|
|
||||||
|
## Кратко
|
||||||
|
|
||||||
|
Обычный `AddBlock` переведён на схему:
|
||||||
|
|
||||||
|
1. сборка `<blockchainName>.tmp_bch`;
|
||||||
|
2. запись sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
|
||||||
|
3. создание пустого marker `<blockchainName>.write_pending`;
|
||||||
|
4. SQL-транзакция;
|
||||||
|
5. атомарная подмена `tmp -> main`;
|
||||||
|
6. удаление временных файлов.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
|
||||||
|
1. Обычный `AddBlock` на свежей цепочке.
|
||||||
|
2. Падение до SQL-commit:
|
||||||
|
- должны остаться только временные файлы;
|
||||||
|
- на старте они должны быть удалены.
|
||||||
|
3. Падение после SQL-commit, но до `atomicReplaceBlockchainFile(...)`:
|
||||||
|
- на старте recovery должен довести swap до конца.
|
||||||
|
4. Падение после `atomicReplaceBlockchainFile(...)`, но до удаления marker/sidecar:
|
||||||
|
- на старте recovery должен просто подчистить хвост.
|
||||||
|
5. Сценарий без marker:
|
||||||
|
- `tmp_bch` / `write_check` считаются мусором и удаляются.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- БД и файловая версия цепочки остаются согласованными.
|
||||||
|
- Повторный старт сервера не ломает chain и не требует ручной правки файлов.
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` корректно обрабатывает и живые остатки, и мусор.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
`pending`
|
||||||
|
|
||||||
|
## Что уже сделано
|
||||||
|
|
||||||
|
- В коде есть `tmp_bch`, `write_check` и `write_pending`.
|
||||||
|
- `BlockchainWriter` пишет обычный `AddBlock` через временные артефакты.
|
||||||
|
- `BlockchainTmpRecoveryOnStartup` умеет добивать или чистить незавершённую запись.
|
||||||
|
|
||||||
|
## Что ещё перепроверить
|
||||||
|
|
||||||
|
- ручной crash-test на тестовом сервере;
|
||||||
|
- совместимость с уже существующими `resync_pending` marker-файлами;
|
||||||
|
- отсутствие ложных срабатываний на старых временных файлах.
|
||||||
@ -9,8 +9,10 @@ import java.util.Objects;
|
|||||||
* FileStoreUtil — утилита работы с файлами в папке data/.
|
* FileStoreUtil — утилита работы с файлами в папке data/.
|
||||||
*
|
*
|
||||||
* Теперь поддерживает:
|
* Теперь поддерживает:
|
||||||
* - основной файл блокчейна: <blockchainName>.bch
|
* - основной файл блокчейна: <blockchainName>.bch
|
||||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
||||||
|
* - sidecar-файл проверки записи: <blockchainName>.write_check
|
||||||
|
* - marker-файл записи: <blockchainName>.write_pending
|
||||||
*
|
*
|
||||||
* Важное:
|
* Важное:
|
||||||
* - validateSimpleFileName() запрещает path traversal.
|
* - validateSimpleFileName() запрещает path traversal.
|
||||||
@ -32,6 +34,12 @@ public final class FileStoreUtil {
|
|||||||
/** Маркер того, что chain сейчас в процессе полного resync. */
|
/** Маркер того, что chain сейчас в процессе полного resync. */
|
||||||
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
||||||
|
|
||||||
|
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
|
||||||
|
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
|
||||||
|
|
||||||
|
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
|
||||||
|
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
|
||||||
|
|
||||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
||||||
|
|
||||||
private final Path dataDirPath;
|
private final Path dataDirPath;
|
||||||
@ -133,6 +141,37 @@ public final class FileStoreUtil {
|
|||||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.write_check */
|
||||||
|
public String buildBlockchainWriteCheckFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
|
||||||
|
StringBuilder sb = new StringBuilder(128);
|
||||||
|
sb.append("blockNumber=").append(blockNumber).append('\n');
|
||||||
|
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
|
||||||
|
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.write_pending */
|
||||||
|
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainWritePendingMarker(String blockchainName) {
|
||||||
|
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/** <blockchainName>.resync_pending */
|
/** <blockchainName>.resync_pending */
|
||||||
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
||||||
validateSimpleFileName(blockchainName);
|
validateSimpleFileName(blockchainName);
|
||||||
@ -167,6 +206,14 @@ public final class FileStoreUtil {
|
|||||||
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
||||||
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,18 +11,32 @@ import shine.db.entities.ChannelNameStateEntry;
|
|||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockchainWriter — запись блока в DB + обновление state + запись в файл.
|
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||||
*
|
*
|
||||||
* ВАЖНО:
|
* Текущая схема:
|
||||||
* - Это минимальный рабочий вариант под новый формат.
|
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||||
* - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом.
|
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||||
|
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||||
|
* 4) выполняем одну SQL-транзакцию;
|
||||||
|
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||||
|
* 6) убираем временные файлы.
|
||||||
|
*
|
||||||
|
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||||
|
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||||
|
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||||
*/
|
*/
|
||||||
public final class BlockchainWriter {
|
public final class BlockchainWriter {
|
||||||
|
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
private final BlocksDAO blocksDAO;
|
private final BlocksDAO blocksDAO;
|
||||||
private final BlockchainStateDAO stateDAO;
|
private final BlockchainStateDAO stateDAO;
|
||||||
private final ChannelNameStateDAO channelNameStateDAO;
|
private final ChannelNameStateDAO channelNameStateDAO;
|
||||||
@ -47,7 +61,13 @@ public final class BlockchainWriter {
|
|||||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
|
byte[] blockBytes = block.toBytes();
|
||||||
|
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||||
|
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||||
|
|
||||||
|
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||||
|
|
||||||
|
boolean committed = false;
|
||||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
try {
|
try {
|
||||||
@ -57,7 +77,7 @@ public final class BlockchainWriter {
|
|||||||
// 2) update state
|
// 2) update state
|
||||||
st.setLastBlockNumber(block.blockNumber);
|
st.setLastBlockNumber(block.blockNumber);
|
||||||
st.setLastBlockHash(block.getHash32());
|
st.setLastBlockHash(block.getHash32());
|
||||||
st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length);
|
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||||
st.setUpdatedAtMs(nowMs);
|
st.setUpdatedAtMs(nowMs);
|
||||||
|
|
||||||
stateDAO.upsert(c, st);
|
stateDAO.upsert(c, st);
|
||||||
@ -72,18 +92,80 @@ public final class BlockchainWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
|
committed = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
try {
|
||||||
if (e instanceof SQLException se) throw se;
|
c.rollback();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!committed) {
|
||||||
|
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof SQLException se) {
|
||||||
|
throw se;
|
||||||
|
}
|
||||||
throw new SQLException("appendBlockAndState failed", e);
|
throw new SQLException("appendBlockAndState failed", e);
|
||||||
} finally {
|
} finally {
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
try {
|
||||||
|
c.setAutoCommit(true);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) append to file (минимально: просто дописать)
|
// 3) После commit — атомарно подменяем основной файл.
|
||||||
// Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут.
|
try {
|
||||||
String fileName = fs.buildBlockchainFileName(blockchainName);
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
fs.addDataToFile(fileName, block.toBytes());
|
} catch (RuntimeException e) {
|
||||||
|
// marker/check/tmp оставляем для startup-recovery
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) После успешной подмены — чистим временные артефакты.
|
||||||
|
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||||
|
byte[] base;
|
||||||
|
try {
|
||||||
|
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||||
|
base = fs.readBlockchain(blockchainName);
|
||||||
|
} else {
|
||||||
|
base = new byte[0];
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] out = new byte[base.length + blockBytes.length];
|
||||||
|
System.arraycopy(base, 0, out, 0, base.length);
|
||||||
|
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||||
|
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||||
|
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||||
|
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteQuietly(DeleteAction action) {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface DeleteAction {
|
||||||
|
void run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,199 +2,299 @@ package server.ws;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
import shine.log.BlockchainAdminNotifier;
|
import shine.log.BlockchainAdminNotifier;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ===============================================================
|
* ===============================================================
|
||||||
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
|
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
|
||||||
* blockchain файлов при старте сервера.
|
* файлов обычного AddBlock при старте сервера.
|
||||||
*
|
*
|
||||||
* Сценарий проблемы:
|
* Новая модель обычной записи:
|
||||||
* - при добавлении блока сначала пишется <name>.tmp_bch
|
* 1) собирается <name>.tmp_bch как полный кандидат на замену main;
|
||||||
* - потом коммитится БД (state.fileSizeBytes)
|
* 2) пишется sidecar <name>.write_check (blockNumber/blockHash);
|
||||||
* - потом tmp переименовывается поверх <name>.bch (атомарно, если возможно)
|
* 3) создаётся пустой marker <name>.write_pending;
|
||||||
|
* 4) выполняется SQL-транзакция;
|
||||||
|
* 5) после commit tmp атомарно ставится на место main;
|
||||||
|
* 6) marker и sidecar удаляются.
|
||||||
*
|
*
|
||||||
* Если сервер упал в середине, может остаться tmp:
|
* На старте:
|
||||||
* - tmp есть, а основной .bch остался старым
|
* - если найден marker, recovery добивает запись или чистит мусор;
|
||||||
* - tmp есть, а основной .bch уже удалили/заменить не успели
|
* - если marker нет, а tmp/check остались, это мусор и он удаляется;
|
||||||
* - tmp есть, а БД успела/не успела обновиться
|
* - legacy tmp-файлы без marker тоже считаются мусором.
|
||||||
*
|
*
|
||||||
* Этот класс при старте:
|
* Принцип:
|
||||||
* - ищет все *.tmp_bch в data/
|
* - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана;
|
||||||
* - сравнивает размеры:
|
* - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash);
|
||||||
* - tmp
|
* - если marker отсутствует, временные файлы не считаются валидными.
|
||||||
* - main (если есть)
|
|
||||||
* - state.fileSizeBytes (если есть)
|
|
||||||
*
|
|
||||||
* Правила:
|
|
||||||
*
|
|
||||||
* A) state есть:
|
|
||||||
* - если stateSize == mainSize => tmp удаляем
|
|
||||||
* - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile)
|
|
||||||
* - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору
|
|
||||||
*
|
|
||||||
* B) state НЕТ:
|
|
||||||
* - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции)
|
|
||||||
* - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера
|
|
||||||
*
|
|
||||||
* Логирование:
|
|
||||||
* - обо всех восстановленных/удалённых tmp пишем в лог
|
|
||||||
* - если tmp-файлов нет — тоже пишем в лог
|
|
||||||
* ===============================================================
|
* ===============================================================
|
||||||
*/
|
*/
|
||||||
public final class BlockchainTmpRecoveryOnStartup {
|
public final class BlockchainTmpRecoveryOnStartup {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
|
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
private BlockchainTmpRecoveryOnStartup() {}
|
private BlockchainTmpRecoveryOnStartup() {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Запуск восстановления.
|
|
||||||
* Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение.
|
|
||||||
*/
|
|
||||||
public static void runRecoveryOrThrow() {
|
public static void runRecoveryOrThrow() {
|
||||||
FileStoreUtil fs = FileStoreUtil.getInstance();
|
FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||||
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
|
BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
|
||||||
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
||||||
ensureDirExists(dataDir);
|
ensureDirExists(dataDir);
|
||||||
|
|
||||||
List<Path> tmpFiles = listTmpFiles(dataDir);
|
List<Path> markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
|
||||||
|
if (!markers.isEmpty()) {
|
||||||
if (tmpFiles.isEmpty()) {
|
log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size());
|
||||||
log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется.");
|
} else {
|
||||||
return;
|
log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено.");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size());
|
for (Path marker : markers) {
|
||||||
|
recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO);
|
||||||
|
}
|
||||||
|
|
||||||
for (Path tmpPath : tmpFiles) {
|
cleanupOrphanTempArtifacts(dataDir, fs);
|
||||||
String fileName = tmpPath.getFileName().toString();
|
log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена.");
|
||||||
String blockchainName = extractBlockchainNameFromTmp(fileName);
|
}
|
||||||
|
|
||||||
if (blockchainName == null || blockchainName.isBlank()) {
|
private static void recoverSingleWriteMarkerOrThrow(Path markerPath,
|
||||||
// странное имя — не трогаем автоматически, но это уже повод дернуть админа
|
FileStoreUtil fs,
|
||||||
BlockchainAdminNotifier.critical(
|
BlockchainStateDAO stateDAO,
|
||||||
"НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).",
|
BlocksDAO blocksDAO) {
|
||||||
null
|
String markerFileName = markerPath.getFileName().toString();
|
||||||
);
|
String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
|
||||||
throw new IllegalStateException("Bad tmp file name: " + fileName);
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
}
|
BlockchainAdminNotifier.critical(
|
||||||
|
"НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
throw new IllegalStateException("Bad write marker name: " + markerFileName);
|
||||||
|
}
|
||||||
|
|
||||||
Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName));
|
Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName);
|
||||||
|
Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName);
|
||||||
|
Path mainPath = fs.resolveBlockchainPath(blockchainName);
|
||||||
|
|
||||||
long tmpSize = safeSize(tmpPath);
|
Map<String, String> meta = parseKeyValueFile(checkPath);
|
||||||
|
Integer expectedBlockNumber = parseInt(meta.get("blockNumber"));
|
||||||
|
String expectedBlockHashHex = normalizeHex(meta.get("blockHash"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName);
|
||||||
boolean mainExists = Files.exists(mainPath);
|
boolean mainExists = Files.exists(mainPath);
|
||||||
|
boolean tmpExists = Files.exists(tmpPath);
|
||||||
long mainSize = mainExists ? safeSize(mainPath) : -1L;
|
long mainSize = mainExists ? safeSize(mainPath) : -1L;
|
||||||
|
long tmpSize = tmpExists ? safeSize(tmpPath) : -1L;
|
||||||
|
|
||||||
BlockchainStateEntry st = null;
|
|
||||||
try {
|
|
||||||
st = stateDAO.getByBlockchainName(blockchainName);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CASE B) state НЕТ
|
|
||||||
// ============================================================
|
|
||||||
if (st == null) {
|
if (st == null) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.",
|
||||||
if (!mainExists) {
|
blockchainName);
|
||||||
// НЕТ state, НЕТ main, есть tmp => удаляем tmp
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}",
|
return;
|
||||||
blockchainName, tmpSize);
|
|
||||||
safeDelete(tmpPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// НЕТ state, но main есть и tmp есть => это уже подозрительно
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " +
|
|
||||||
"blockchainName=" + blockchainName +
|
|
||||||
", mainSize=" + mainSize +
|
|
||||||
", tmpSize=" + tmpSize +
|
|
||||||
". СЕРВЕР ОСТАНОВЛЕН. " +
|
|
||||||
"ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CASE A) state ЕСТЬ
|
|
||||||
// ============================================================
|
|
||||||
long stateSize = st.getFileSizeBytes();
|
long stateSize = st.getFileSizeBytes();
|
||||||
|
|
||||||
// 1) stateSize == mainSize => tmp мусор
|
if (expectedBlockNumber == null || expectedBlockHashHex == null) {
|
||||||
if (mainExists && mainSize == stateSize) {
|
log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.",
|
||||||
log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
|
blockchainName);
|
||||||
blockchainName, stateSize, mainSize, tmpSize);
|
|
||||||
safeDelete(tmpPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main
|
if (mainExists && mainSize == stateSize) {
|
||||||
if (tmpSize == stateSize) {
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
|
return;
|
||||||
blockchainName, stateSize, mainSize, tmpSize);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE
|
|
||||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
|
||||||
|
|
||||||
// после move tmp должен исчезнуть сам (перемещён)
|
|
||||||
log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}",
|
|
||||||
blockchainName, safeSize(mainPath));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
BlockchainAdminNotifier.critical(
|
|
||||||
"НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " +
|
|
||||||
"blockchainName=" + blockchainName +
|
|
||||||
", stateSize=" + stateSize +
|
|
||||||
", mainSize=" + mainSize +
|
|
||||||
", tmpSize=" + tmpSize +
|
|
||||||
". СЕРВЕР ОСТАНОВЛЕН.",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e);
|
|
||||||
}
|
}
|
||||||
continue;
|
if (tmpExists && tmpSize == stateSize) {
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tmpExists && mainExists) {
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация
|
BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber);
|
||||||
|
if (block == null || block.getBlockHash() == null) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.",
|
||||||
|
blockchainName, expectedBlockNumber);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String actualHashHex = HEX.formatHex(block.getBlockHash());
|
||||||
|
if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) {
|
||||||
|
log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.",
|
||||||
|
blockchainName, expectedBlockHashHex, actualHashHex);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если main уже совпадает со state — tmp/check/marker лишние.
|
||||||
|
if (mainExists && mainSize == stateSize) {
|
||||||
|
log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}",
|
||||||
|
blockchainName, stateSize, mainSize);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если tmp уже готов и совпадает со state — просто ставим его на место main.
|
||||||
|
if (tmpExists && tmpSize == stateSize) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}",
|
||||||
|
blockchainName, stateSize, tmpSize);
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если tmp нет, но DB уже закоммитила блок — пробуем восстановить tmp из main + block_bytes.
|
||||||
|
if (!tmpExists && mainExists) {
|
||||||
|
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
|
||||||
|
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.",
|
||||||
|
blockchainName);
|
||||||
|
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
|
||||||
|
fs.writeBlockchainTmp(blockchainName, rebuilt);
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes.
|
||||||
|
if (mainExists) {
|
||||||
|
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
|
||||||
|
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}",
|
||||||
|
blockchainName);
|
||||||
|
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
|
||||||
|
fs.writeBlockchainTmp(blockchainName, rebuilt);
|
||||||
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
|
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если ничего не совпало, это уже подозрительное состояние.
|
||||||
BlockchainAdminNotifier.critical(
|
BlockchainAdminNotifier.critical(
|
||||||
"ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " +
|
"НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName +
|
||||||
"blockchainName=" + blockchainName +
|
|
||||||
", stateSize=" + stateSize +
|
", stateSize=" + stateSize +
|
||||||
", mainExists=" + mainExists +
|
", mainExists=" + mainExists +
|
||||||
", mainSize=" + mainSize +
|
", mainSize=" + mainSize +
|
||||||
|
", tmpExists=" + tmpExists +
|
||||||
", tmpSize=" + tmpSize +
|
", tmpSize=" + tmpSize +
|
||||||
". СЕРВЕР ОСТАНОВЛЕН. " +
|
", expectedBlockNumber=" + expectedBlockNumber +
|
||||||
"ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.",
|
", expectedBlockHash=" + expectedBlockHashHex +
|
||||||
|
". Требуется ручная проверка.",
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
throw new IllegalStateException("Blockchain files mismatch for " + blockchainName);
|
throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
BlockchainAdminNotifier.critical(
|
||||||
|
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================================================================== */
|
private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) {
|
||||||
/* =============================== Helpers ============================== */
|
List<Path> tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
|
||||||
/* ===================================================================== */
|
List<Path> checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
|
||||||
|
|
||||||
|
if (tmpFiles.isEmpty() && checkFiles.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size());
|
||||||
|
for (Path tmp : tmpFiles) {
|
||||||
|
String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
|
||||||
|
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
safeDelete(tmp);
|
||||||
|
}
|
||||||
|
for (Path check : checkFiles) {
|
||||||
|
String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
|
||||||
|
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
safeDelete(check);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) {
|
||||||
|
safeDelete(markerPath);
|
||||||
|
safeDelete(checkPath);
|
||||||
|
safeDelete(tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) {
|
||||||
|
try {
|
||||||
|
byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0];
|
||||||
|
byte[] out = new byte[mainBytes.length + blockBytes.length];
|
||||||
|
System.arraycopy(mainBytes, 0, out, 0, mainBytes.length);
|
||||||
|
System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length);
|
||||||
|
return out;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseKeyValueFile(Path path) {
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
if (path == null || !Files.exists(path)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
|
||||||
|
if (line == null) continue;
|
||||||
|
String s = line.trim();
|
||||||
|
if (s.isEmpty() || s.startsWith("#")) continue;
|
||||||
|
int idx = s.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
String key = s.substring(0, idx).trim();
|
||||||
|
String value = s.substring(idx + 1).trim();
|
||||||
|
result.put(key, value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Cannot read write_check file: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer parseInt(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHex(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureDirExists(Path dir) {
|
private static void ensureDirExists(Path dir) {
|
||||||
try {
|
try {
|
||||||
@ -206,31 +306,27 @@ public final class BlockchainTmpRecoveryOnStartup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Path> listTmpFiles(Path dataDir) {
|
private static List<Path> listFilesWithSuffix(Path dataDir, String suffix) {
|
||||||
List<Path> out = new ArrayList<>();
|
List<Path> out = new ArrayList<>();
|
||||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) {
|
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + suffix)) {
|
||||||
for (Path p : ds) {
|
for (Path p : ds) {
|
||||||
if (Files.isRegularFile(p)) out.add(p);
|
if (Files.isRegularFile(p)) {
|
||||||
|
out.add(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e);
|
throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static String extractBlockchainName(String fileName, String suffix) {
|
||||||
* Из "anya0001.tmp_bch" -> "anya0001"
|
if (fileName == null) return null;
|
||||||
*/
|
String s = fileName.trim();
|
||||||
private static String extractBlockchainNameFromTmp(String tmpFileName) {
|
if (!s.endsWith(suffix)) return null;
|
||||||
if (tmpFileName == null) return null;
|
String base = s.substring(0, s.length() - suffix.length());
|
||||||
if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null;
|
|
||||||
|
|
||||||
String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length());
|
|
||||||
|
|
||||||
// базовая защита: не допускаем слэши/.. даже если кто-то подложил файл
|
|
||||||
if (base.isBlank()) return null;
|
if (base.isBlank()) return null;
|
||||||
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
|
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,4 +345,4 @@ public final class BlockchainTmpRecoveryOnStartup {
|
|||||||
throw new IllegalStateException("Cannot delete file: " + p, e);
|
throw new IllegalStateException("Cannot delete file: " + p, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.276
|
client.version=1.2.277
|
||||||
server.version=1.2.256
|
server.version=1.2.257
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user