Repository navigation
backport: bitcoin#33212, bitcoin#34897 (don't commit indexes ahead of the flushed chainstate) #8030
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
254fe94
582821e
319074f
c05252f
e8325a1
337c464
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -245,12 +245,23 @@ void BaseIndex::ThreadSync() | |||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| bool BaseIndex::Commit() | ||||||||||
| void BaseIndex::Commit() | ||||||||||
| { | ||||||||||
| // Don't commit anything if we haven't indexed any block yet | ||||||||||
| // (this could happen if init is interrupted). | ||||||||||
| bool ok = m_best_block_index != nullptr; | ||||||||||
| if (ok) { | ||||||||||
| // Don't commit if the index best block is not an ancestor of the chainstate's last flushed | ||||||||||
| // block. Otherwise, after an unclean shutdown, the index could be | ||||||||||
| // persisted ahead of a chainstate it can no longer roll back to, which | ||||||||||
| // would corrupt indexes with state (e.g. coinstatsindex). | ||||||||||
| const CBlockIndex* index_tip = m_best_block_index.load(); | ||||||||||
| const CBlockIndex* last_flushed = WITH_LOCK(::cs_main, return m_chainstate->GetLastFlushedBlock()); | ||||||||||
| if (!last_flushed || last_flushed->GetAncestor(index_tip->nHeight) != index_tip) { | ||||||||||
| LogPrint(BCLog::COINDB, "Skipping commit, index is ahead of flushed chainstate (index height %d, last flush at height %d)\n", | ||||||||||
| index_tip->nHeight, last_flushed ? last_flushed->nHeight : -1); | ||||||||||
| return; | ||||||||||
|
PastaPastaPasta marked this conversation as resolved.
Resolved
Comment on lines
+260
to
+263
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Preserve recovery information for eager writes when skipping the checkpoint This return also skips ThreadSync's end-of-sync checkpoint after AddressIndex, SpentIndex, and TimestampIndex have already persisted their rows in CustomAppend(). Let background synchronization finish at T above the last full chainstate flush F, with T's block, undo data, and block-index entry already durable. That state is possible because validation.cpp:3264–3304 has a periodic-write path that persists those files and the block index without flushing CoinsTip. After a crash, restart with the optional index disabled, reorganize away T, and re-enable the index. Its saved locator still names F, so synchronization follows the replacement branch without invoking CustomRewind() for the orphaned rows above F. The index can then report synced while returning stale address history/UTXOs or spent/timestamp results. The author's comparison with v23 and rc.6 does not establish safety against this PR's actual base: bf9a6cb already resumes from locator.vHave[0], and its unconditional end-of-sync Commit() records T. Because T survives this scenario, that base can rewind it when the index is re-enabled. This is therefore distinct from the documented limitation involving blocks lost from the on-disk block index. Keep the checkpoint guard, but retain recovery information for eager writes beyond the committed locator, and add coverage for the durable-block crash followed by a reorganization while the index is disabled. source:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correction (after reproducing it): my ChainLock and "hour-old" reasoning below was wrong. A periodic block-index write can make a block durable seconds after it connects, and an unlocked tip can be reorged without crossing a ChainLock. I reproduced the scenario deterministically with a local unit test (not part of this PR):
rc.6 commits T, but its pre-bitcoin#25193 Original reply (the ChainLock point is superseded)Agreed on the comparison with this PR's base: there, the end-of-sync commit records T. If a periodic block-index write made T durable before the crash, a later rewind from T can erase the orphaned rows. This PR leaves the locator at F instead. I'm still not treating it as blocking for this backport:
🤖 Posted autonomously by Claude on behalf of pasta.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree the checkpoint guard fixes the missing-block-index startup failure and should stay. However, the hourly write does not imply that the blocks it persists are an hour old: |
||||||||||
| } | ||||||||||
| CDBBatch batch(GetDB()); | ||||||||||
| ok = CustomCommit(batch); | ||||||||||
| if (ok) { | ||||||||||
|
|
@@ -260,9 +271,19 @@ bool BaseIndex::Commit() | |||||||||
| } | ||||||||||
| if (!ok) { | ||||||||||
| LogError("%s: Failed to commit latest %s state\n", __func__, GetName()); | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| return true; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| void BaseIndex::WriteRewindLocator(CDBBatch& batch, const CBlockIndex* new_tip) const | ||||||||||
| { | ||||||||||
| CBlockLocator locator; | ||||||||||
| if (!GetDB().ReadBestBlock(locator) || locator.IsNull()) return; | ||||||||||
| const CBlockIndex* committed = WITH_LOCK(::cs_main, return m_chainstate->m_blockman.LookupBlockIndex(locator.vHave.front())); | ||||||||||
| // The committed tip was an ancestor of a flushed block when it was written, so it and new_tip below it are in | ||||||||||
| // the block index after a restart. A committed tip at or below new_tip, or on another branch, needs no change. | ||||||||||
| if (committed && committed->nHeight > new_tip->nHeight && committed->GetAncestor(new_tip->nHeight) == new_tip) { | ||||||||||
| GetDB().WriteBestBlock(batch, GetLocator(*m_chain, new_tip->GetBlockHash())); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| bool BaseIndex::Rewind(const CBlockIndex* current_tip, const CBlockIndex* new_tip) | ||||||||||
|
|
@@ -274,18 +295,13 @@ bool BaseIndex::Rewind(const CBlockIndex* current_tip, const CBlockIndex* new_ti | |||||||||
| return false; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // In the case of a reorg, ensure persisted block locator is not stale. | ||||||||||
| // Don't commit here - the committed index state must never be ahead of the | ||||||||||
| // flushed chainstate, otherwise unclean restarts would lead to index corruption. | ||||||||||
| // Pruning has a minimum of 288 blocks-to-keep and getting the index | ||||||||||
| // out of sync may be possible but a users fault. | ||||||||||
| // In case we reorg beyond the pruned depth, ReadBlockFromDisk would | ||||||||||
| // throw and lead to a graceful shutdown | ||||||||||
| SetBestBlockIndex(new_tip); | ||||||||||
| if (!Commit()) { | ||||||||||
| // If commit fails, revert the best block index to avoid corruption. | ||||||||||
| SetBestBlockIndex(current_tip); | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return true; | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| // Copyright (c) 2020-present The Bitcoin Core developers | ||
| // Distributed under the MIT software license, see the accompanying | ||
| // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||
|
|
||
| #include <chain.h> | ||
| #include <consensus/validation.h> | ||
| #include <index/coinstatsindex.h> | ||
| #include <interfaces/chain.h> | ||
| #include <node/blockstorage.h> | ||
| #include <script/script.h> | ||
| #include <test/util/index.h> | ||
| #include <test/util/setup_common.h> | ||
| #include <util/check.h> | ||
| #include <validation.h> | ||
| #include <validationinterface.h> | ||
|
|
||
| #include <boost/test/unit_test.hpp> | ||
|
|
||
| namespace { | ||
| //! A minimal index that, like Dash's address, spent and timestamp indexes, keeps one entry per block and erases the | ||
| //! entries of rewound blocks in CustomRewind(). The rewind hooks are exposed so a test can leave the on-disk state | ||
| //! that an unclean shutdown in the middle of a reorg would leave, without having to time an actual crash. | ||
| class ErasingIndex final : public BaseIndex | ||
| { | ||
| const std::unique_ptr<BaseIndex::DB> m_db; | ||
|
|
||
| public: | ||
| explicit ErasingIndex(std::unique_ptr<interfaces::Chain> chain) | ||
| : BaseIndex(std::move(chain), "erasing test index"), | ||
| m_db{std::make_unique<BaseIndex::DB>(gArgs.GetDataDirNet() / "indexes" / "erasingtest", /*n_cache_size=*/1 << 20)} | ||
| { | ||
| } | ||
| ~ErasingIndex() override { BaseIndex::Stop(); } | ||
|
|
||
| bool Indexed(const CBlockIndex* block) const { return m_db->Exists(std::make_pair(uint8_t{'e'}, block->GetBlockHash())); } | ||
| bool RewindTo(const CBlockIndex* current_tip, const CBlockIndex* new_tip) | ||
| { | ||
| return CustomRewind({current_tip->GetBlockHash(), current_tip->nHeight}, {new_tip->GetBlockHash(), new_tip->nHeight}); | ||
| } | ||
| void CommitLocator(const CBlockIndex* tip) | ||
| { | ||
| CDBBatch batch(*m_db); | ||
| m_db->WriteBestBlock(batch, GetLocator(tip)); | ||
| BOOST_REQUIRE(m_db->WriteBatch(batch)); | ||
| } | ||
| void RewindLocatorTo(const CBlockIndex* new_tip) | ||
| { | ||
| CDBBatch batch(*m_db); | ||
| WriteRewindLocator(batch, new_tip); | ||
| BOOST_REQUIRE(m_db->WriteBatch(batch)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| uint256 CommittedTip() const | ||
| { | ||
| CBlockLocator locator; | ||
| BOOST_REQUIRE(m_db->ReadBestBlock(locator) && !locator.IsNull()); | ||
| return locator.vHave.front(); | ||
| } | ||
|
|
||
| protected: | ||
| bool CustomAppend(const interfaces::BlockInfo& block) override { return m_db->Write(std::make_pair(uint8_t{'e'}, block.hash), true); } | ||
| bool CustomRewind(const interfaces::BlockKey& current_tip, const interfaces::BlockKey& new_tip) override | ||
| { | ||
| const CBlockIndex* new_tip_index{WITH_LOCK(::cs_main, return m_chainstate->m_blockman.LookupBlockIndex(new_tip.hash))}; | ||
| for (const CBlockIndex* pindex{WITH_LOCK(::cs_main, return m_chainstate->m_blockman.LookupBlockIndex(current_tip.hash))}; | ||
| pindex != new_tip_index; pindex = pindex->pprev) { | ||
| if (!m_db->Erase(std::make_pair(uint8_t{'e'}, pindex->GetBlockHash()))) return false; | ||
| } | ||
| CDBBatch batch(*m_db); | ||
| WriteRewindLocator(batch, new_tip_index); | ||
| return m_db->WriteBatch(batch); | ||
| } | ||
| BaseIndex::DB& GetDB() const override { return *m_db; } | ||
| bool AllowPrune() const override { return true; } | ||
| }; | ||
|
|
||
| const CBlockIndex* AtHeight(ChainstateManager& chainman, int height) | ||
| { | ||
| return WITH_LOCK(::cs_main, return chainman.ActiveChain()[height]); | ||
| } | ||
|
|
||
| //! Makes the chainstate's last flushed block `height`, below the current tip, by flushing while the blocks above | ||
| //! are disconnected and then connecting them again without a flush. | ||
| void LowerLastFlushedBlock(ChainstateManager& chainman, int height) | ||
| { | ||
| Chainstate& chainstate{chainman.ActiveChainstate()}; | ||
| CBlockIndex* first_disconnected{WITH_LOCK(::cs_main, return chainman.ActiveChain()[height + 1])}; | ||
| BlockValidationState state; | ||
| BOOST_REQUIRE(chainstate.InvalidateBlock(state, first_disconnected)); | ||
| chainstate.ForceFlushStateToDisk(); | ||
| WITH_LOCK(::cs_main, chainstate.ResetBlockFailureFlags(first_disconnected)); | ||
| BOOST_REQUIRE(chainstate.ActivateBestChain(state)); | ||
| BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainstate.GetLastFlushedBlock()->nHeight), height); | ||
| } | ||
| } // namespace | ||
|
|
||
| // Tests of generic BaseIndex functionality that is independent of which | ||
| // concrete index is being used. CoinStatsIndex is used here merely as a | ||
| // convenient instantiation of BaseIndex. | ||
| BOOST_AUTO_TEST_SUITE(baseindex_tests) | ||
|
|
||
| // Test that the index does not commit ahead of the chainstate's last | ||
| // flushed block. If it did, a subsequent unclean shutdown would corrupt | ||
| // the index, because during reverting it would require blocks that were | ||
| // never flushed to disk. | ||
| BOOST_FIXTURE_TEST_CASE(baseindex_no_commit_ahead_of_flush, TestChain100Setup) | ||
| { | ||
| Chainstate& chainstate = Assert(m_node.chainman)->ActiveChainstate(); | ||
| auto sync_index = [&](bool do_flush, int expected_sync_height, int expected_commit_height) { | ||
| { | ||
| CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1 << 20}; | ||
| BOOST_REQUIRE(index.Start()); | ||
| IndexWaitSynced(index); | ||
| if (do_flush) { | ||
| chainstate.ForceFlushStateToDisk(); | ||
| SyncWithValidationInterfaceQueue(); | ||
| } | ||
| BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, expected_sync_height); | ||
| index.Stop(); | ||
| } | ||
| // Reload index to see which block data was actually committed. Hold back the | ||
| // sync thread so the loaded best block is not advanced before it is read. | ||
| node::g_indexes_ready_to_sync = false; | ||
| { | ||
| CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1 << 20}; | ||
| BOOST_REQUIRE(index.Start()); | ||
| BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, expected_commit_height); | ||
| index.Interrupt(); | ||
| index.Stop(); | ||
| } | ||
| node::g_indexes_ready_to_sync = true; | ||
| }; | ||
|
|
||
| // Part 1: Sync, then "crash" (stop without flushing). Models a node that | ||
| // started up, had its index catch up, but never flushed before going down. | ||
| // The end-of-sync Commit() runs at chain tip (height 100) but the last | ||
| // flushed block is behind it, so it is skipped. | ||
| sync_index(false, 100, 0); | ||
|
|
||
| // Part 2: Restart cleanly. Sync, force a chainstate flush, and drain the | ||
| // validation queue so the index's ChainStateFlushed callback runs. | ||
| // Now the last flushed block == tip == 100 and the index can commit. | ||
| sync_index(true, 100, 100); | ||
|
|
||
| // Part 3: Connect a new block on the chain without flushing | ||
| // (the last flushed block stays at 100). For a real node this would happen | ||
| // in parallel with Sync(). Here we do it before Sync() to make the race | ||
| // state deterministic. | ||
| CreateAndProcessBlock({}, CScript() << OP_TRUE); | ||
| sync_index(false, 101, 100); | ||
| } | ||
|
|
||
| // An index that erases entries when it rewinds must not leave its committed locator above them, even while the | ||
| // rewound tip is ahead of the last flush and Commit() can't write it. | ||
| BOOST_FIXTURE_TEST_CASE(rewind_locator_follows_erased_entries, TestChain100Setup) | ||
| { | ||
| ChainstateManager& chainman{*Assert(m_node.chainman)}; | ||
| Chainstate& chainstate{chainman.ActiveChainstate()}; | ||
|
|
||
| // A competing block at height 98 that stays off the active chain | ||
| CBlockIndex* old_98{WITH_LOCK(::cs_main, return chainman.ActiveChain()[98])}; | ||
| BlockValidationState state; | ||
| BOOST_REQUIRE(chainstate.InvalidateBlock(state, old_98)); | ||
| const uint256 competing_98{CreateAndProcessBlock({}, CScript() << OP_TRUE).GetHash()}; | ||
| WITH_LOCK(::cs_main, chainstate.ResetBlockFailureFlags(old_98)); | ||
| BOOST_REQUIRE(chainstate.ActivateBestChain(state)); | ||
| const CBlockIndex* competing{WITH_LOCK(::cs_main, return chainman.m_blockman.LookupBlockIndex(competing_98))}; | ||
| BOOST_REQUIRE_EQUAL(AtHeight(chainman, 100)->nHeight, 100); | ||
| chainstate.ForceFlushStateToDisk(); | ||
| BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainstate.GetLastFlushedBlock()->nHeight), 100); | ||
|
|
||
| node::g_indexes_ready_to_sync = false; | ||
| ErasingIndex index{interfaces::MakeChain(m_node)}; | ||
| BOOST_REQUIRE(index.Start()); | ||
|
|
||
| // Committed tip above the target on the same branch: the locator moves down to the target | ||
| index.CommitLocator(AtHeight(chainman, 100)); | ||
| index.RewindLocatorTo(AtHeight(chainman, 97)); | ||
| BOOST_CHECK(index.CommittedTip() == AtHeight(chainman, 97)->GetBlockHash()); | ||
|
|
||
| // Committed tip equal to the target: unchanged | ||
| index.CommitLocator(AtHeight(chainman, 100)); | ||
| index.RewindLocatorTo(AtHeight(chainman, 100)); | ||
| BOOST_CHECK(index.CommittedTip() == AtHeight(chainman, 100)->GetBlockHash()); | ||
|
|
||
| // Target on another branch: unchanged, never lowered to a common ancestor below entries that stay | ||
| index.RewindLocatorTo(competing); | ||
| BOOST_CHECK(index.CommittedTip() == AtHeight(chainman, 100)->GetBlockHash()); | ||
|
|
||
| // Committed tip below the target: unchanged, a restart re-appends from it | ||
| index.CommitLocator(AtHeight(chainman, 95)); | ||
| index.RewindLocatorTo(AtHeight(chainman, 97)); | ||
| BOOST_CHECK(index.CommittedTip() == AtHeight(chainman, 95)->GetBlockHash()); | ||
|
|
||
| index.Interrupt(); | ||
| index.Stop(); | ||
| node::g_indexes_ready_to_sync = true; | ||
| } | ||
|
|
||
| // A rewind persists its erasures right away. If the node then stops before the rewound tip is flushed and the | ||
| // rewound blocks are back on the active chain, the next start must index them again. | ||
| BOOST_FIXTURE_TEST_CASE(restart_after_rewind_reindexes_erased_blocks, TestChain100Setup) | ||
| { | ||
| ChainstateManager& chainman{*Assert(m_node.chainman)}; | ||
| { | ||
| ErasingIndex index{interfaces::MakeChain(m_node)}; | ||
| BOOST_REQUIRE(index.Start()); | ||
| IndexWaitSynced(index); | ||
| chainman.ActiveChainstate().ForceFlushStateToDisk(); | ||
| SyncWithValidationInterfaceQueue(); | ||
| index.Stop(); | ||
| } | ||
| // The rewind target (97) is now ahead of the last flush (95), so Commit() could not persist it | ||
| LowerLastFlushedBlock(chainman, 95); | ||
| const CBlockIndex* tip{AtHeight(chainman, 100)}; | ||
|
|
||
| node::g_indexes_ready_to_sync = false; | ||
| { | ||
| ErasingIndex index{interfaces::MakeChain(m_node)}; | ||
| BOOST_REQUIRE(index.Start()); | ||
| BOOST_CHECK(index.CommittedTip() == tip->GetBlockHash()); | ||
| BOOST_REQUIRE(index.RewindTo(tip, AtHeight(chainman, 97))); | ||
| BOOST_CHECK(!index.Indexed(tip)); | ||
| index.Interrupt(); | ||
| index.Stop(); | ||
| } | ||
| node::g_indexes_ready_to_sync = true; | ||
|
|
||
| // The rewound blocks are still the active chain, as after a restart in which their branch wins again | ||
| ErasingIndex index{interfaces::MakeChain(m_node)}; | ||
| BOOST_REQUIRE(index.Start()); | ||
| IndexWaitSynced(index); | ||
| for (int height{98}; height <= 100; ++height) { | ||
| BOOST_CHECK_MESSAGE(index.Indexed(AtHeight(chainman, height)), "block " << height << " was not indexed again"); | ||
| } | ||
| index.Stop(); | ||
| } | ||
|
|
||
| BOOST_AUTO_TEST_SUITE_END() | ||
Uh oh!
There was an error while loading. Please reload this page.