Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ BITCOIN_TESTS =\
test/base32_tests.cpp \
test/base58_tests.cpp \
test/base64_tests.cpp \
test/baseindex_tests.cpp \
test/bech32_tests.cpp \
test/bip32_tests.cpp \
test/bip324_tests.cpp \
Expand Down
4 changes: 3 additions & 1 deletion src/index/addressindex.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,9 @@ bool AddressIndex::CustomRewind(const interfaces::BlockKey& current_tip, const i
}
}

return true;
CDBBatch locator_batch(GetDB());
WriteRewindLocator(locator_batch, new_tip_index);
return GetDB().WriteBatch(locator_batch);
Comment thread
PastaPastaPasta marked this conversation as resolved.
Resolved
}

BaseIndex::DB& AddressIndex::GetDB() const { return *m_db; }
Expand Down
36 changes: 26 additions & 10 deletions src/index/base.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
PastaPastaPasta marked this conversation as resolved.
Resolved
Comment on lines +260 to +263

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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: gpt-6.1-sol (phase2-reviewer: general)

@PastaPastaPasta PastaPastaPasta Nov 3, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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):

  1. The index sync ends at T above the last chainstate flush.
  2. The index stops with no further commit, modelling an unclean stop.
  3. T's branch is reorged away while the index is not running.
  4. The index restarts.
Tree Orphaned address rows after restart
develop base (bf9a6cb) rewound, rows gone (3/3)
this PR rows still returned (3/3)
v24.0.0-rc.6 rows still returned (3/3)

rc.6 commits T, but its pre-bitcoin#25193 Init() resolves the locator with FindForkInGlobalIndex and starts at the fork point, so it never rewinds the orphaned blocks. So this is a regression against the unreleased develop base, not against rc.6 or v23.1.10. Leaving the thread open for a maintainer decision.


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:

  • The base handles the more common version of this crash worse. On the base, if T's block-index entry is not durable, Init() stops the node with "best block of the index not found" (Indexes stuck on unknown best block after unclean shutdown bitcoin/bitcoin#33208), the failure indexes: Don't commit ahead of the flushed chainstate bitcoin/bitcoin#34897 fixes here.
  • On Dash the remaining case needs a reorg past ChainLocks. (Wrong, see the correction above.)
  • The enabled-index crash case is related. With the index enabled, the same rows above F are already left behind after a crash: on the base, on v23.1.10 and with this PR.
  • The fix is not small. Keeping recovery information for rows above the committed locator needs a new persisted marker that Init() starts from and the rewind code also moves.

🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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: fPeriodicWrite in FlushStateToDisk() measures time since the previous write, so a newly connected T can become durable immediately before background index sync finishes, without a full coins flush. Chainlocks::HasConflictingChainLock() returns false above the best known ChainLock height, so reorganizing that fresh, unlocked tip does not require crossing a ChainLock. The existing enabled-index crash limitation is real, but it does not make this additional loss of recovery relative to the base pre-existing; I therefore can't accept deferring this regression solely because the recovery-marker change is larger.

}
CDBBatch batch(GetDB());
ok = CustomCommit(batch);
if (ok) {
Expand All @@ -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)
Expand All @@ -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;
}

Expand Down
17 changes: 9 additions & 8 deletions src/index/base.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,10 @@ class BaseIndex : public CValidationInterface
void ThreadSync();

/// Write the current index state (eg. chain block locator and subclass-specific items) to disk.
///
/// Recommendations for error handling:
/// If called on a successor of the previous committed best block in the index, the index can
/// continue processing without risk of corruption, though the index state will need to catch up
/// from further behind on reboot. If the new state is not a successor of the previous state (due
/// to a chain reorganization), the index must halt until Commit succeeds or else it could end up
/// getting corrupted.
bool Commit();
/// Will skip the commit if no block has been indexed yet or if the index's best block is
/// ahead of the chainstate's last flushed block. This avoids persisting state an unclean shutdown
/// could not roll back from. A later call commits when the chainstate has flushed far enough.
void Commit();

/// Loop over disconnected blocks and call CustomRewind.
bool Rewind(const CBlockIndex* current_tip, const CBlockIndex* new_tip);
Expand Down Expand Up @@ -121,6 +117,11 @@ class BaseIndex : public CValidationInterface
/// be an ancestor of the current best block.
[[nodiscard]] virtual bool CustomRewind(const interfaces::BlockKey& current_tip, const interfaces::BlockKey& new_tip) { return true; }

/// For indexes whose CustomRewind() erases entries: if the committed locator is above new_tip on the same
/// branch, move it to new_tip. Commit() may not write new_tip yet (it can be ahead of the last flush), but
/// leaving the old locator above erased entries would make an unclean restart resume past them.
void WriteRewindLocator(CDBBatch& batch, const CBlockIndex* new_tip) const;

virtual DB& GetDB() const = 0;

/// Get the name of the index for display in logs.
Expand Down
4 changes: 3 additions & 1 deletion src/index/govcollateralindex.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ bool GovCollateralIndex::CustomRewind(const interfaces::BlockKey& current_tip, c
m_db->EraseCollateral(batch, txid);
}
}
m_db->WriteBestBlock(batch, GetLocator(new_tip_index));
// Never persist a locator ahead of the chainstate's last flushed block (after an unclean shutdown the block
// index might not know it, and the index would refuse to load), but don't leave it above the erasures either.
WriteRewindLocator(batch, new_tip_index);
return m_db->WriteBatch(batch);
}

Expand Down
4 changes: 3 additions & 1 deletion src/index/spentindex.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ bool SpentIndex::CustomRewind(const interfaces::BlockKey& current_tip, const int
}
}

return true;
CDBBatch locator_batch(GetDB());
WriteRewindLocator(locator_batch, new_tip_index);
return GetDB().WriteBatch(locator_batch);
}

BaseIndex::DB& SpentIndex::GetDB() const { return *m_db; }
Expand Down
4 changes: 3 additions & 1 deletion src/index/timestampindex.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ bool TimestampIndex::CustomRewind(const interfaces::BlockKey& current_tip, const
}
}

return true;
CDBBatch locator_batch(GetDB());
WriteRewindLocator(locator_batch, new_tip_index);
return GetDB().WriteBatch(locator_batch);
}

BaseIndex::DB& TimestampIndex::GetDB() const { return *m_db; }
Expand Down
238 changes: 238 additions & 0 deletions src/test/baseindex_tests.cpp
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));
Comment thread
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()
Loading
Loading