From 71fdee0cfdc61dd4954c0649442b52bc12819783f9fe72eb78089a78ad9c3fa9 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 26 Jun 2026 17:05:37 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D1=80=D0=BD=D1=83=D1=82=D1=8C=20cr?= =?UTF-8?q?ash-safe=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20AddBlock=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20tmp=5Fbch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/Blockchain/CHANGELOG.md | 8 + Dev_Docs/Blockchain/README.md | 1 + Dev_Docs/Blockchain/sync-between-servers.md | 27 +- Dev_Docs/Future_Features/README.md | 1 - ...6-26_1215_tmp_bch_для_обычного_addblock.md | 45 -- ...26-06-26_1500_addblock_tmp_bch_recovery.md | 47 +++ .../main/java/utils/files/FileStoreUtil.java | 51 ++- .../BlockchainWriter.java | 106 ++++- .../ws/BlockchainTmpRecoveryOnStartup.java | 394 +++++++++++------- VERSION.properties | 4 +- 10 files changed, 468 insertions(+), 216 deletions(-) delete mode 100644 Dev_Docs/Future_Features/near/2026-06-26_1215_tmp_bch_для_обычного_addblock.md create mode 100644 Dev_Docs/Pending_Features/2026-06-26_1500_addblock_tmp_bch_recovery.md diff --git a/Dev_Docs/Blockchain/CHANGELOG.md b/Dev_Docs/Blockchain/CHANGELOG.md index 3feb46b..a231764 100644 --- a/Dev_Docs/Blockchain/CHANGELOG.md +++ b/Dev_Docs/Blockchain/CHANGELOG.md @@ -1,5 +1,13 @@ # История изменений документации блокчейна +## 2026-06-26 17:03:22 +0400 +- Базовый коммит-ориентир: `TBD`. +- Обычный `AddBlock` переведён на crash-safe схему через временный кандидат `.tmp_bch`, sidecar `.write_check` и marker `.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 - Базовый коммит-ориентир: `abdce05`. - `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`. diff --git a/Dev_Docs/Blockchain/README.md b/Dev_Docs/Blockchain/README.md index 4f3177d..39587f7 100644 --- a/Dev_Docs/Blockchain/README.md +++ b/Dev_Docs/Blockchain/README.md @@ -30,4 +30,5 @@ ## Обязательное сопровождение - При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений. +- Обычный `AddBlock` сейчас пишет через `.tmp_bch`, `.write_check` и `.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery. - Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. diff --git a/Dev_Docs/Blockchain/sync-between-servers.md b/Dev_Docs/Blockchain/sync-between-servers.md index 6b72dc7..8a2f1d6 100644 --- a/Dev_Docs/Blockchain/sync-between-servers.md +++ b/Dev_Docs/Blockchain/sync-between-servers.md @@ -113,11 +113,28 @@ Full resync запускается только тогда, когда: - full resync не трогает DM-таблицы и `solana_users`; - висячие cross-chain ссылки считаются допустимым поведением системы. -### 4.5 Startup recovery по marker-file +### 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` для `*.tmp_bch`; +1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`; 2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`; 3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`. @@ -127,7 +144,7 @@ Full resync запускается только тогда, когда: - recovery снова выполняет cleanup и replay с нуля; - если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain. -### 4.6 Зачем понадобился `GetSyncUserProfile` +### 4.7 Зачем понадобился `GetSyncUserProfile` Изначально подготовка локальной цепочки делалась через Solana: @@ -145,7 +162,7 @@ Full resync запускается только тогда, когда: Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint. -### 4.7 Что делает настройка `sync.importUserProfileFromPartner.enabled` +### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled` - `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA; - `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`. @@ -209,6 +226,7 @@ Full resync запускается только тогда, когда: | Публичный `GetSyncUserProfile` | ✅ Реализовано | | Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | | Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | +| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | | Межсерверный постоянный WebSocket-канал | Нужна реализация | | Push новых DM партнёрам | Нужна реализация | | Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | @@ -221,5 +239,4 @@ Full resync запускается только тогда, когда: Не реализованы ещё DM-sync и постоянные server-to-server соединения. Следующие отдельные шаги после текущего этапа: -- вернуть обычному `AddBlock` настоящую `tmp_bch`-схему записи и recovery при резком рестарте. - отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md index e1b5cdd..68bccfd 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/Dev_Docs/Future_Features/README.md @@ -30,7 +30,6 @@ - `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. ### Среднесрочные diff --git a/Dev_Docs/Future_Features/near/2026-06-26_1215_tmp_bch_для_обычного_addblock.md b/Dev_Docs/Future_Features/near/2026-06-26_1215_tmp_bch_для_обычного_addblock.md deleted file mode 100644 index 7a0d995..0000000 --- a/Dev_Docs/Future_Features/near/2026-06-26_1215_tmp_bch_для_обычного_addblock.md +++ /dev/null @@ -1,45 +0,0 @@ -# Вернуть настоящую tmp_bch-схему для обычного AddBlock - -## Зачем нужна эта доработка - -Сейчас обычный `AddBlock` пишет данные так: - -1. в SQL-транзакции добавляет блок в БД и обновляет `blockchain_state`; -2. делает `commit`; -3. только после этого дописывает основной файл `.bch`. - -Это рабочая схема, но она не идеально crash-safe. Если сервер резко упадёт между `commit` БД и записью файла, можно получить состояние: - -- в БД новый блок уже есть; -- в `.bch` файла этого блока ещё нет. - -При этом в проекте уже есть логика startup-recovery через `*.tmp_bch`, но текущий `BlockchainWriter` её полноценно не использует. Из-за этого writer и recovery сейчас живут в разных моделях. - -## Что планируем сделать - -Нужно вернуть единую и понятную схему: - -1. обычный `AddBlock` работает через временный файл `.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` делать следующим самостоятельным шагом. diff --git a/Dev_Docs/Pending_Features/2026-06-26_1500_addblock_tmp_bch_recovery.md b/Dev_Docs/Pending_Features/2026-06-26_1500_addblock_tmp_bch_recovery.md new file mode 100644 index 0000000..de2f63e --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-26_1500_addblock_tmp_bch_recovery.md @@ -0,0 +1,47 @@ +# Crash-safe запись обычного `AddBlock` через `tmp_bch` + +## Кратко + +Обычный `AddBlock` переведён на схему: + +1. сборка `.tmp_bch`; +2. запись sidecar `.write_check` с `blockNumber` и `blockHash`; +3. создание пустого marker `.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-файлами; +- отсутствие ложных срабатываний на старых временных файлах. diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java index 6b15ed8..a7550fb 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java @@ -9,8 +9,10 @@ import java.util.Objects; * FileStoreUtil — утилита работы с файлами в папке data/. * * Теперь поддерживает: - * - основной файл блокчейна: .bch - * - временный файл блокчейна: .tmp_bch + * - основной файл блокчейна: .bch + * - временный файл блокчейна: .tmp_bch + * - sidecar-файл проверки записи: .write_check + * - marker-файл записи: .write_pending * * Важное: * - validateSimpleFileName() запрещает path traversal. @@ -32,6 +34,12 @@ public final class FileStoreUtil { /** Маркер того, что chain сейчас в процессе полного resync. */ 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 final Path dataDirPath; @@ -133,6 +141,37 @@ public final class FileStoreUtil { newFile(buildBlockchainTmpFileName(blockchainName), data); } + /** .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)); + } + + /** .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]); + } + /** .resync_pending */ public String buildBlockchainResyncMarkerFileName(String blockchainName) { validateSimpleFileName(blockchainName); @@ -167,6 +206,14 @@ public final class FileStoreUtil { deleteIfExists(resolveBlockchainTmpPath(blockchainName)); } + public void deleteBlockchainWriteCheckIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName)); + } + + public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName)); + } + public void deleteBlockchainResyncMarkerIfExists(String blockchainName) { deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName)); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 387829f..6a5ab99 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -11,18 +11,32 @@ import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.UserParamEntry; 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.SQLException; +import java.util.HexFormat; /** - * BlockchainWriter — запись блока в DB + обновление state + запись в файл. + * BlockchainWriter — запись блока в БД и формирование файловой версии цепочки. * - * ВАЖНО: - * - Это минимальный рабочий вариант под новый формат. - * - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом. + * Текущая схема: + * 1) собираем .tmp_bch как готовый кандидат на замену основного файла; + * 2) пишем маленький sidecar .write_check с blockNumber/blockHash; + * 3) создаём пустой marker .write_pending; + * 4) выполняем одну SQL-транзакцию; + * 5) после commit атомарно заменяем основной .bch из tmp; + * 6) убираем временные файлы. + * + * Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись: + * - если marker отсутствует, а tmp/check остались, это мусор; + * - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор. */ public final class BlockchainWriter { + private static final HexFormat HEX = HexFormat.of(); + private final BlocksDAO blocksDAO; private final BlockchainStateDAO stateDAO; private final ChannelNameStateDAO channelNameStateDAO; @@ -47,7 +61,13 @@ public final class BlockchainWriter { ChannelNameStateEntry channelNameStateEntry) throws SQLException { 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()) { c.setAutoCommit(false); try { @@ -57,7 +77,7 @@ public final class BlockchainWriter { // 2) update state st.setLastBlockNumber(block.blockNumber); st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length); + st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length); st.setUpdatedAtMs(nowMs); stateDAO.upsert(c, st); @@ -72,18 +92,80 @@ public final class BlockchainWriter { } c.commit(); + committed = true; } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - if (e instanceof SQLException se) throw se; + try { + c.rollback(); + } catch (Exception ignored) { + } + + if (!committed) { + cleanupWriteArtifactsBestEffort(blockchainName); + } + + if (e instanceof SQLException se) { + throw se; + } throw new SQLException("appendBlockAndState failed", e); } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} + try { + c.setAutoCommit(true); + } catch (Exception ignored) { + } } } - // 3) append to file (минимально: просто дописать) - // Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут. - String fileName = fs.buildBlockchainFileName(blockchainName); - fs.addDataToFile(fileName, block.toBytes()); + // 3) После commit — атомарно подменяем основной файл. + try { + fs.atomicReplaceBlockchainFile(blockchainName); + } 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(); } } diff --git a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java index 5a2bd92..8e063df 100644 --- a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java +++ b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java @@ -2,199 +2,299 @@ package server.ws; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import shine.db.dao.BlocksDAO; import shine.db.dao.BlockchainStateDAO; +import shine.db.entities.BlockEntry; import shine.db.entities.BlockchainStateEntry; import utils.files.FileStoreUtil; import shine.log.BlockchainAdminNotifier; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.HexFormat; /** * =============================================================== * BlockchainTmpRecoveryOnStartup — восстановление консистентности - * blockchain файлов при старте сервера. + * файлов обычного AddBlock при старте сервера. * - * Сценарий проблемы: - * - при добавлении блока сначала пишется .tmp_bch - * - потом коммитится БД (state.fileSizeBytes) - * - потом tmp переименовывается поверх .bch (атомарно, если возможно) + * Новая модель обычной записи: + * 1) собирается .tmp_bch как полный кандидат на замену main; + * 2) пишется sidecar .write_check (blockNumber/blockHash); + * 3) создаётся пустой marker .write_pending; + * 4) выполняется SQL-транзакция; + * 5) после commit tmp атомарно ставится на место main; + * 6) marker и sidecar удаляются. * - * Если сервер упал в середине, может остаться tmp: - * - tmp есть, а основной .bch остался старым - * - tmp есть, а основной .bch уже удалили/заменить не успели - * - tmp есть, а БД успела/не успела обновиться + * На старте: + * - если найден marker, recovery добивает запись или чистит мусор; + * - если marker нет, а tmp/check остались, это мусор и он удаляется; + * - legacy tmp-файлы без marker тоже считаются мусором. * - * Этот класс при старте: - * - ищет все *.tmp_bch в data/ - * - сравнивает размеры: - * - tmp - * - main (если есть) - * - state.fileSizeBytes (если есть) - * - * Правила: - * - * A) state есть: - * - если stateSize == mainSize => tmp удаляем - * - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile) - * - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору - * - * B) state НЕТ: - * - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции) - * - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера - * - * Логирование: - * - обо всех восстановленных/удалённых tmp пишем в лог - * - если tmp-файлов нет — тоже пишем в лог + * Принцип: + * - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана; + * - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash); + * - если marker отсутствует, временные файлы не считаются валидными. * =============================================================== */ public final class BlockchainTmpRecoveryOnStartup { private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class); + private static final HexFormat HEX = HexFormat.of(); private BlockchainTmpRecoveryOnStartup() {} - /** - * Запуск восстановления. - * Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение. - */ public static void runRecoveryOrThrow() { FileStoreUtil fs = FileStoreUtil.getInstance(); BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + BlocksDAO blocksDAO = BlocksDAO.getInstance(); Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); ensureDirExists(dataDir); - List tmpFiles = listTmpFiles(dataDir); - - if (tmpFiles.isEmpty()) { - log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется."); - return; + List markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); + if (!markers.isEmpty()) { + log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size()); + } else { + log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено."); } - log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size()); + for (Path marker : markers) { + recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO); + } - for (Path tmpPath : tmpFiles) { - String fileName = tmpPath.getFileName().toString(); - String blockchainName = extractBlockchainNameFromTmp(fileName); + cleanupOrphanTempArtifacts(dataDir, fs); + log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена."); + } - if (blockchainName == null || blockchainName.isBlank()) { - // странное имя — не трогаем автоматически, но это уже повод дернуть админа - BlockchainAdminNotifier.critical( - "НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).", - null - ); - throw new IllegalStateException("Bad tmp file name: " + fileName); - } + private static void recoverSingleWriteMarkerOrThrow(Path markerPath, + FileStoreUtil fs, + BlockchainStateDAO stateDAO, + BlocksDAO blocksDAO) { + String markerFileName = markerPath.getFileName().toString(); + String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); + 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 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 tmpExists = Files.exists(tmpPath); 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 (!mainExists) { - // НЕТ state, НЕТ main, есть tmp => удаляем tmp - log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}", - 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); + log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.", + blockchainName); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; } - // ============================================================ - // CASE A) state ЕСТЬ - // ============================================================ long stateSize = st.getFileSizeBytes(); - // 1) stateSize == mainSize => tmp мусор - if (mainExists && mainSize == stateSize) { - log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", - blockchainName, stateSize, mainSize, tmpSize); - safeDelete(tmpPath); - continue; - } + if (expectedBlockNumber == null || expectedBlockHashHex == null) { + log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.", + blockchainName); - // 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main - if (tmpSize == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", - 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); + if (mainExists && mainSize == stateSize) { + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; } - 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( - "ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " + - "blockchainName=" + blockchainName + + "НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName + ", stateSize=" + stateSize + ", mainExists=" + mainExists + ", mainSize=" + mainSize + + ", tmpExists=" + tmpExists + ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН. " + - "ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.", + ", expectedBlockNumber=" + expectedBlockNumber + + ", expectedBlockHash=" + expectedBlockHashHex + + ". Требуется ручная проверка.", 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-файлов завершена."); } - /* ===================================================================== */ - /* =============================== Helpers ============================== */ - /* ===================================================================== */ + private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) { + List tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); + List 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 parseKeyValueFile(Path path) { + Map 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) { try { @@ -206,31 +306,27 @@ public final class BlockchainTmpRecoveryOnStartup { } } - private static List listTmpFiles(Path dataDir) { + private static List listFilesWithSuffix(Path dataDir, String suffix) { List out = new ArrayList<>(); - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) { + try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + suffix)) { for (Path p : ds) { - if (Files.isRegularFile(p)) out.add(p); + if (Files.isRegularFile(p)) { + out.add(p); + } } } 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; } - /** - * Из "anya0001.tmp_bch" -> "anya0001" - */ - private static String extractBlockchainNameFromTmp(String tmpFileName) { - if (tmpFileName == null) return null; - if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null; - - String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length()); - - // базовая защита: не допускаем слэши/.. даже если кто-то подложил файл + private static String extractBlockchainName(String fileName, String suffix) { + if (fileName == null) return null; + String s = fileName.trim(); + if (!s.endsWith(suffix)) return null; + String base = s.substring(0, s.length() - suffix.length()); if (base.isBlank()) return null; if (base.contains("/") || base.contains("\\") || base.contains("..")) return null; - return base; } @@ -249,4 +345,4 @@ public final class BlockchainTmpRecoveryOnStartup { throw new IllegalStateException("Cannot delete file: " + p, e); } } -} \ No newline at end of file +} diff --git a/VERSION.properties b/VERSION.properties index b49fb38..df84d46 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.276 -server.version=1.2.256 +client.version=1.2.277 +server.version=1.2.257