From 49d5e835a87a1475329176290f53ddd6f2c2d2ec Mon Sep 17 00:00:00 2001 From: Fabian Jahr Date: Tue, 23 Sep 2025 23:04:47 +0200 Subject: [PATCH] rpc: Don't invalidate blocks in dumptxoutset Instead this new approach uses a temporary coins db to roll back the UTXO set. This new approach also prevents the node from pruning necessary blocks during dumptxoutset execution by using prune locks. --- src/rpc/blockchain.cpp | 289 +++++++++++++++++++--------- src/rpc/blockchain.h | 2 +- test/functional/rpc_dumptxoutset.py | 19 -- 3 files changed, 197 insertions(+), 113 deletions(-) diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index d898cd42806..50697a530c9 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -91,6 +91,14 @@ UniValue WriteUTXOSnapshot( const fs::path& temppath, const std::function& interruption_point = {}); +UniValue CreateRolledBackUTXOSnapshot( + NodeContext& node, + Chainstate& chainstate, + const CBlockIndex* target, + AutoFile&& afile, + const fs::path& path, + const fs::path& tmppath); + /* Calculate the difficulty for a given block index. */ double GetDifficulty(const CBlockIndex& blockindex) @@ -3031,39 +3039,26 @@ static RPCMethod getblockfilter() } /** - * RAII class that disables the network in its constructor and enables it in its - * destructor. + * RAII class that registers a prune lock in its constructor to prevent + * block data from being pruned, and removes it in its destructor. */ -class NetworkDisable +class TemporaryPruneLock { - CConnman& m_connman; + static constexpr const char* LOCK_NAME{"dumptxoutset-rollback"}; + BlockManager& m_blockman; public: - NetworkDisable(CConnman& connman) : m_connman(connman) { - m_connman.SetNetworkActive(false); - if (m_connman.GetNetworkActive()) { - throw JSONRPCError(RPC_MISC_ERROR, "Network activity could not be suspended."); - } - }; - ~NetworkDisable() { - m_connman.SetNetworkActive(true); - }; -}; - -/** - * RAII class that temporarily rolls back the local chain in it's constructor - * and rolls it forward again in it's destructor. - */ -class TemporaryRollback -{ - ChainstateManager& m_chainman; - const CBlockIndex& m_invalidate_index; -public: - TemporaryRollback(ChainstateManager& chainman, const CBlockIndex& index) : m_chainman(chainman), m_invalidate_index(index) { - InvalidateBlock(m_chainman, m_invalidate_index.GetBlockHash()); - }; - ~TemporaryRollback() { - ReconsiderBlock(m_chainman, m_invalidate_index.GetBlockHash()); - }; + TemporaryPruneLock(BlockManager& blockman, int height) : m_blockman(blockman) + { + LOCK(::cs_main); + m_blockman.UpdatePruneLock(LOCK_NAME, {height}); + LogDebug(BCLog::PRUNE, "dumptxoutset: registered prune lock at height %d", height); + } + ~TemporaryPruneLock() + { + LOCK(::cs_main); + m_blockman.DeletePruneLock(LOCK_NAME); + LogDebug(BCLog::PRUNE, "dumptxoutset: released prune lock"); + } }; /** @@ -3075,10 +3070,9 @@ static RPCMethod dumptxoutset() { return RPCMethod{ "dumptxoutset", - "Write the serialized UTXO set to a file. This can be used in loadtxoutset afterwards if this snapshot height is supported in the chainparams as well.\n\n" - "Unless the \"latest\" type is requested, the node will roll back to the requested height and network activity will be suspended during this process. " - "Because of this it is discouraged to interact with the node in any other way during the execution of this call to avoid inconsistent results and race conditions, particularly RPCs that interact with blockstorage.\n\n" - "This call may take several minutes. Make sure to use no RPC timeout (bitcoin-cli -rpcclienttimeout=0)", + "Write the serialized UTXO set to a file. This can be used in loadtxoutset afterwards if this snapshot height is supported in the chainparams as well.\n" + "This creates a temporary UTXO database when rolling back, keeping the main chain intact. Should the node experience an unclean shutdown the temporary database may need to be removed from the datadir manually.\n" + "For deep rollbacks, make sure to use no RPC timeout (bitcoin-cli -rpcclienttimeout=0) as it may take several minutes.", { {"path", RPCArg::Type::STR, RPCArg::Optional::NO, "Path to the output file. If relative, will be prefixed by datadir."}, {"type", RPCArg::Type::STR, RPCArg::Default(""), "The type of snapshot to create. Can be \"latest\" to create a snapshot of the current UTXO set or \"rollback\" to temporarily roll back the state of the node to a historical block before creating the snapshot of a historical UTXO set. This parameter can be omitted if a separate \"rollback\" named parameter is specified indicating the height or hash of a specific historical block. If \"rollback\" is specified and separate \"rollback\" named parameter is not specified, this will roll back to the latest valid snapshot block that can currently be loaded with loadtxoutset."}, @@ -3151,16 +3145,15 @@ static RPCMethod dumptxoutset() "Couldn't open file " + temppath.utf8string() + " for writing."); } - CConnman& connman = EnsureConnman(node); - const CBlockIndex* invalidate_index{nullptr}; - std::optional disable_network; - std::optional temporary_rollback; - - // If the user wants to dump the txoutset of the current tip, we don't have - // to roll back at all - if (target_index != tip) { - // If the node is running in pruned mode we ensure all necessary block - // data is available before starting to roll back. + UniValue result; + Chainstate& chainstate{node.chainman->ActiveChainstate()}; + if (target_index == tip) { + // Dump the txoutset of the current tip + result = CreateUTXOSnapshot(node, chainstate, std::move(afile), path, temppath); + } else { + // Check pruning constraints before attempting rollback and prevent + // pruning of the necessary blocks with a temporary prune lock + std::optional temp_prune_lock; if (node.chainman->m_blockman.IsPruneMode()) { LOCK(node.chainman->GetMutex()); const CBlockIndex* current_tip{node.chainman->ActiveChain().Tip()}; @@ -3168,68 +3161,178 @@ static RPCMethod dumptxoutset() if (first_block.nHeight > target_index->nHeight) { throw JSONRPCError(RPC_MISC_ERROR, "Could not roll back to requested height since necessary block data is already pruned."); } + temp_prune_lock.emplace(node.chainman->m_blockman, target_index->nHeight); } - // Suspend network activity for the duration of the process when we are - // rolling back the chain to get a utxo set from a past height. We do - // this so we don't punish peers that send us that send us data that - // seems wrong in this temporary state. For example a normal new block - // would be classified as a block connecting an invalid block. - // Skip if the network is already disabled because this - // automatically re-enables the network activity at the end of the - // process which may not be what the user wants. - if (connman.GetNetworkActive()) { - disable_network.emplace(connman); - } - - invalidate_index = WITH_LOCK(::cs_main, return node.chainman->ActiveChain().Next(target_index)); - temporary_rollback.emplace(*node.chainman, *invalidate_index); + result = CreateRolledBackUTXOSnapshot(node, + chainstate, + target_index, + std::move(afile), + path, + temppath); } - Chainstate* chainstate; - std::unique_ptr cursor; - CCoinsStats stats; - { - // Lock the chainstate before calling PrepareUtxoSnapshot, to be able - // to get a UTXO database cursor while the chain is pointing at the - // target block. After that, release the lock while calling - // WriteUTXOSnapshot. The cursor will remain valid and be used by - // WriteUTXOSnapshot to write a consistent snapshot even if the - // chainstate changes. - LOCK(node.chainman->GetMutex()); - chainstate = &node.chainman->ActiveChainstate(); - // In case there is any issue with a block being read from disk we need - // to stop here, otherwise the dump could still be created for the wrong - // height. - // The new tip could also not be the target block if we have a stale - // sister block of invalidate_index. This block (or a descendant) would - // be activated as the new tip and we would not get to new_tip_index. - if (target_index != chainstate->m_chain.Tip()) { - LogWarning("dumptxoutset failed to roll back to requested height, reverting to tip.\n"); - throw JSONRPCError(RPC_MISC_ERROR, "Could not roll back to requested height."); - } else { - std::tie(cursor, stats, tip) = PrepareUTXOSnapshot(*chainstate, node.rpc_interruption_point); - } - } - - UniValue result = WriteUTXOSnapshot(*chainstate, - cursor.get(), - &stats, - tip, - std::move(afile), - path, - temppath, - node.rpc_interruption_point); if (!fs::is_fifo(path_info)) { fs::rename(temppath, path); } - result.pushKV("path", path.utf8string()); return result; }, }; } +/** + * RAII class that creates a temporary database directory in its constructor + * and removes it in its destructor. + */ +class TemporaryUTXODatabase +{ + fs::path m_path; +public: + TemporaryUTXODatabase(const fs::path& path) : m_path(path) { + fs::create_directories(m_path); + } + ~TemporaryUTXODatabase() { + if (!DestroyDB(fs::PathToString(m_path))) { + LogInfo("Failed to clean up temporary UTXO database at %s, please remove it manually.", + fs::PathToString(m_path)); + } + } +}; + +UniValue CreateRolledBackUTXOSnapshot( + NodeContext& node, + Chainstate& chainstate, + const CBlockIndex* target, + AutoFile&& afile, + const fs::path& path, + const fs::path& tmppath) +{ + // Create a temporary leveldb to store the UTXO set that is being rolled back + std::string temp_db_name{strprintf("temp_utxo_%d", target->nHeight)}; + fs::path temp_db_path{fsbridge::AbsPathJoin(tmppath.parent_path(), fs::u8path(temp_db_name))}; + TemporaryUTXODatabase temp_db_cleaner{temp_db_path}; + + // Create temporary database + DBParams db_params{ + .path = temp_db_path, + .cache_bytes = 0, + .memory_only = false, + .wipe_data = true, + .obfuscate = false, + .options = DBOptions{} + }; + + std::unique_ptr temp_db = std::make_unique( + std::move(db_params), + CoinsViewOptions{} + ); + + const CBlockIndex* tip = nullptr; + LogInfo("Copying current UTXO set to temporary database."); + { + CCoinsViewCache temp_cache(temp_db.get()); + std::unique_ptr cursor; + { + LOCK(::cs_main); + tip = chainstate.m_chain.Tip(); + chainstate.ForceFlushStateToDisk(/*wipe_cache=*/false); + cursor = chainstate.CoinsDB().Cursor(); + } + temp_cache.SetBestBlock(tip->GetBlockHash()); + + size_t coins_count = 0; + while (cursor->Valid()) { + node.rpc_interruption_point(); + + COutPoint key; + Coin coin; + if (cursor->GetKey(key) && cursor->GetValue(coin)) { + temp_cache.AddCoin(key, std::move(coin), false); + coins_count++; + + // Log every 10M coins (optimized for mainnet) + if (coins_count % 10'000'000 == 0) { + LogInfo("Copying UTXO set: %uM coins copied.", coins_count / 1'000'000); + } + + // Flush periodically + if (coins_count % 100'000 == 0) { + temp_cache.Flush(); + } + } + cursor->Next(); + } + + temp_cache.Flush(); + LogInfo("UTXO set copy complete: %u coins total", coins_count); + } + + LogInfo("Rolling back from height %d to %d", tip->nHeight, target->nHeight); + + const CBlockIndex* block_index{tip}; + const size_t total_blocks{static_cast(block_index->nHeight - target->nHeight)}; + CCoinsViewCache rollback_cache(temp_db.get()); + rollback_cache.SetBestBlock(block_index->GetBlockHash()); + size_t blocks_processed = 0; + int last_progress{0}; + DisconnectResult res; + + while (block_index->nHeight > target->nHeight) { + node.rpc_interruption_point(); + + CBlock block; + if (!node.chainman->m_blockman.ReadBlock(block, *block_index)) { + throw JSONRPCError(RPC_INTERNAL_ERROR, + strprintf("Failed to read block at height %d", block_index->nHeight)); + } + + WITH_LOCK(::cs_main, res = chainstate.DisconnectBlock(block, block_index, rollback_cache)); + if (res == DISCONNECT_FAILED) { + throw JSONRPCError(RPC_INTERNAL_ERROR, + strprintf("Failed to roll back block at height %d", block_index->nHeight)); + } + + blocks_processed++; + int progress{static_cast(blocks_processed * 100 / total_blocks)}; + if (progress >= last_progress + 5) { + LogInfo("Rolled back %d%% of blocks.", progress); + last_progress = progress; + rollback_cache.Flush(); + } + + block_index = block_index->pprev; + } + + CHECK_NONFATAL(rollback_cache.GetBestBlock() == target->GetBlockHash()); + rollback_cache.Flush(); + + LogInfo("Rollback complete. Computing UTXO statistics for created txoutset dump."); + std::optional maybe_stats = GetUTXOStats(temp_db.get(), + chainstate.m_blockman, + CoinStatsHashType::HASH_SERIALIZED, + node.rpc_interruption_point); + + if (!maybe_stats) { + throw JSONRPCError(RPC_INTERNAL_ERROR, "Unable to compute UTXO statistics"); + } + + std::unique_ptr pcursor{temp_db->Cursor()}; + if (!pcursor) { + throw JSONRPCError(RPC_INTERNAL_ERROR, "Unable to create UTXO cursor"); + } + + LogInfo("Writing snapshot to disk."); + return WriteUTXOSnapshot(chainstate, + pcursor.get(), + &(*maybe_stats), + target, + std::move(afile), + path, + tmppath, + node.rpc_interruption_point); +} + std::tuple, CCoinsStats, const CBlockIndex*> PrepareUTXOSnapshot( Chainstate& chainstate, diff --git a/src/rpc/blockchain.h b/src/rpc/blockchain.h index 53f6a804f99..45f62c628b0 100644 --- a/src/rpc/blockchain.h +++ b/src/rpc/blockchain.h @@ -47,7 +47,7 @@ UniValue blockheaderToJSON(const CBlockIndex& tip, const CBlockIndex& blockindex void CalculatePercentilesByWeight(CAmount result[NUM_GETBLOCKSTATS_PERCENTILES], std::vector>& scores, int64_t total_weight); /** - * Test-only helper to create UTXO snapshots given a chainstate and a file handle. + * Helper to create UTXO snapshots given a chainstate and a file handle. * @return a UniValue map containing metadata about the snapshot. */ UniValue CreateUTXOSnapshot( diff --git a/test/functional/rpc_dumptxoutset.py b/test/functional/rpc_dumptxoutset.py index 5edcddf0e42..90f635cef13 100755 --- a/test/functional/rpc_dumptxoutset.py +++ b/test/functional/rpc_dumptxoutset.py @@ -19,17 +19,6 @@ class DumptxoutsetTest(BitcoinTestFramework): self.setup_clean_chain = True self.num_nodes = 1 - def check_expected_network(self, node, active): - rev_file = node.blocks_path / "rev00000.dat" - bogus_file = node.blocks_path / "bogus.dat" - rev_file.rename(bogus_file) - assert_raises_rpc_error( - -1, 'Could not roll back to requested height.', node.dumptxoutset, 'utxos.dat', rollback=99) - assert_equal(node.getnetworkinfo()['networkactive'], active) - - # Cleanup - bogus_file.rename(rev_file) - def run_test(self): """Test a trivial usage of the dumptxoutset RPC command.""" node = self.nodes[0] @@ -71,14 +60,6 @@ class DumptxoutsetTest(BitcoinTestFramework): assert_raises_rpc_error( -8, 'Invalid snapshot type "bogus" specified. Please specify "rollback" or "latest"', node.dumptxoutset, 'utxos.dat', "bogus") - self.log.info("Test that dumptxoutset failure does not leave the network activity suspended when it was on previously") - self.check_expected_network(node, True) - - self.log.info("Test that dumptxoutset failure leaves the network activity suspended when it was off") - node.setnetworkactive(False) - self.check_expected_network(node, False) - node.setnetworkactive(True) - if __name__ == '__main__': DumptxoutsetTest(__file__).main()