indexes: Don't commit ahead of the flushed chainstate#34897
Conversation
|
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code Coverage & BenchmarksFor details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34897. ReviewsSee the guideline and AI policy for information on the review process.
If your review is incorrectly listed, please copy-paste ConflictsReviewers, this pull request conflicts with the following ones:
If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first. LLM Linter (✨ experimental)Possible places where named args for integral literals may be used (e.g.
2026-07-09 14:22:11 |
|
Concept ACK |
|
I have reported something similar in #34489 (comment) and #34489 (comment) - will this PR fix those? |
There was a problem hiding this comment.
Concept ACK [5af4d6e]
However, I'm concerned about a potential starvation scenario during IBD. Two threads run in parallel: the validation thread connects blocks and periodically flushes the UTXO set to disk (~1h intervals or when the cache is full), while the index thread (Sync()) processes already-connected blocks and calls Commit() every 30 seconds. Since index processing (filters, tx lookups) is significantly lighter than UTXO set updates, the index will typically stay ahead of the flush point, even though it's always behind the chain tip.
This means that during IBD, every Commit() call could be skipped because the index is perpetually ahead of m_last_flushed_block. A crash at any point would lose all indexing progress back to the initial LoadChainTip value, potentially hours of work. I will run experiments to verify whether this actually happens and propose a solution in that case.
I also suggest discussing an alternative approach for index commits: committing the locator up to the flushed block instead of skipping entirely. For example, if the index has processed up to block 5000 but the last flush was at block 3000, the locator could be written at 3000. This would preserve the safety invariant (locator never exceeds the flush point) while still persisting progress. That said, this would require rethinking how Commit() and CustomCommit() work across index subclasses.
This request also concerns me from a performance perspective: if index commit progress is now bounded by the chainstate flush interval, any index-side speed optimization becomes less impactful. PRs like #34489 (batch DB writes) and #26966 (parallelized index sync) focus on making indexes faster, but with this change the bottleneck shifts to how often the chainstate flushes, something entirely outside the index's control. I would love to see those optimizations land in Core, so it would be worth ensuring this safety fix doesn't inadvertently cap their benefits.
mzumsande
left a comment
There was a problem hiding this comment.
However, I'm concerned about a potential starvation scenario during IBD. Two threads run in parallel: the validation thread connects blocks and periodically flushes the UTXO set to disk (~1h intervals or when the cache is full), while the index thread (
Sync()) processes already-connected blocks and callsCommit()every 30 seconds. Since index processing (filters, tx lookups) is significantly lighter than UTXO set updates, the index will typically stay ahead of the flush point, even though it's always behind the chain tip.
The Sync thread runs only temporary, when the index is behind the chainstate (e.g. newly started on a node that was running without an index before). For example, if a new node starts IBD with an index, Sync will stop immediately and never do any work.
As soon as the index has caught up with the tip, the Sync thread is stopped and we use validation signals to keep on syncing. Once that happens, the index will commit with each ChainstateFlushed validation callback, and since validation signals are processed in order, I don't think its best block can ever be ahead of the flushed chainstate then.
So I don't think the problem you describe is an issue. Commits will only be skipped in the limited time span where the Sync thread is almost caught up, due to additional blocks being connected during the earlier sync. This should be a relatively short period. They won't be skipped if the index is catching up from far behind, or once the index has reached the tip.
This request also concerns me from a performance perspective
Commit doesn't do any significant work, this is being done in ProcessBlock(), which will write things to disk independently. Commit only writes the data required to know where we should pick up at the next start (including state data such as the current MuHash of the coinstatsindex:
If we don't Commit and have an unclean restart, we might have to do some work again in case of an unclean restart, if we do Commit ahead of the tip and have an unclean restart, the index is corrupted.
I'll address the other comments later.
|
Concept ACK 5af4d6e. Good to follow up on this issue. I feel like this could skip adding the I also implemented a similar fix in 6537f96 from #24230 which has some differences like the check being done inside Sync() instead of Commit(). Not sure if the differences are significant though. |
| // Flush the chainstate (which may refer to block index entries). | ||
| empty_cache ? CoinsTip().Flush() : CoinsTip().Sync(); | ||
| m_last_flushed_block = m_chain.Tip(); |
There was a problem hiding this comment.
Coming from the lack of m_chain usage in this function, I'm wondering if we should add a sanity-check here?
Assume(m_last_flushed_block->GetBlockHash() == CoinsTip().GetBestBlock());or.. maybe directly use
m_last_flushed_block = m_blockman.LookupBlockIndex(CoinsTip().GetBestBlock());There was a problem hiding this comment.
489bb4a validation: track last flushed block:
Could this new ChainStateFlushed locator come from the recorded flushed block instead of rereading m_chain.Tip()?
diff --git a/src/validation.cpp b/src/validation.cpp
--- a/src/validation.cpp (revision 30c92e346762ec5d046d81d46d08811ccd9e26b3)
+++ b/src/validation.cpp (revision 2296c4e0101ee7de230478d0dd7323b04a849fe4)
@@ -2831,7 +2831,7 @@
}
if (full_flush_completed && m_chainman.m_options.signals) {
// Update best block in wallet (so we can detect restored wallets).
- m_chainman.m_options.signals->ChainStateFlushed(this->GetRole(), GetLocator(m_chain.Tip()));
+ m_chainman.m_options.signals->ChainStateFlushed(this->GetRole(), GetLocator(Assert(m_last_flushed_block)));
}
} catch (const std::runtime_error& e) {
return FatalError(m_chainman.GetNotifications(), state, strprintf(_("System error while flushing: %s"), e.what()));There was a problem hiding this comment.
I added the suggested sanity check, and reused m_last_flushed. Note that we did use m_chain for the ChainStateFlushed() notification there.
However, I just noticed that some unit tests (validation_chainstate_resize_caches) don't populate m_chain and fail with the Assume. For now I have fixed this by making it conditional to a tip existing - I'll look later into changing the test instead.
There was a problem hiding this comment.
Still doesn't work. The Assume is also hit by the validation_chainstatemanager_tests which manually changes the tip to be lagging behind the CoinsTip()- this is a dirty hack to make AssumeUtxo testable in unit tests (Snapshots will only activate when they have more work than the tip...).
I have removed the Assume() for now. Changing it to CoinsTip() would work, but then we should probably also change the notification. Would you prefer that?
There was a problem hiding this comment.
@furszy is probably correct, the cached boundary should reflect the coins view that was actually flushed. But the ChainStateFlushed() locator change I asked about should probably stay as-is.
I understand that during IBD we can't have reorgs, but during steady state DisconnectTip(), the temporary coins view is flushed back to pindexDelete->pprev before the active tip is updated, so m_chain.Tip() can still name the disconnected block while CoinsTip().GetBestBlock() already names its parent:
struct TestChain100SetupNoMempoolHeadroom : TestChain100Setup {
TestChain100SetupNoMempoolHeadroom() : TestChain100Setup{ChainType::REGTEST, TestOpts{.extra_args = {"-maxmempool=0"}}} {}
};
BOOST_FIXTURE_TEST_CASE(flushed_boundary_uses_disconnected_coins_tip, TestChain100SetupNoMempoolHeadroom)
{
Chainstate& chainstate{Assert(m_node.chainman)->ActiveChainstate()};
const CBlockIndex* tip{Assert(WITH_LOCK(::cs_main, return chainstate.m_chain.Tip()))};
const CBlockIndex* parent{tip->pprev};
{
LOCK2(m_node.chainman->GetMutex(), chainstate.MempoolMutex());
chainstate.m_coinstip_cache_size_bytes = 0;
BlockValidationState state{};
BOOST_REQUIRE(chainstate.DisconnectTip(state, nullptr));
}
BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return chainstate.m_chain.Tip()), parent);
BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return chainstate.GetLastFlushedBlock()), parent);
m_node.validation_signals->SyncWithValidationInterfaceQueue();
}The test fails currently because GetLastFlushedBlock() records the disconnected tip instead of its parent:
m_last_flushed_block = m_blockman.LookupBlockIndex(CoinsTip().GetBestBlock());There was a problem hiding this comment.
That is an interesting situation, which I think is a bug in master. So the issue is that we call FlushStateToDisk(state, FlushStateMode::IF_NEEDED)) in DisconnectTip() after disconnecting the coins, but before setting the new tip (the IF_NEEDED makes this relatively unlikely, but it is still possible).
But the ChainStateFlushed() locator change I asked about should probably stay as-is.
why? It gives the wrong locator for the notification in this situation.
I changed it to use CoinsTip().GetBestBlock() for the last flushed block and also added a commit at the end to no longer use m_chain.Tip() for the existing ChainStateFlushed notification.
| // Don't commit if the index is ahead of the chainstate's last flushed block | ||
| const CBlockIndex* index_tip = m_best_block_index.load(); | ||
| if (index_tip && !m_chain->isBlockInFlushedChain(index_tip->GetBlockHash(), index_tip->nHeight)) { | ||
| LogInfo("Skipping commit, index is ahead of flushed chainstate"); |
There was a problem hiding this comment.
nit: Could log where the index is at vs the chain height, so the diff gets logged.
There was a problem hiding this comment.
nit: Also LogInfo on every skip seems a bit excessive. Maybe make this a debug log?
There was a problem hiding this comment.
I added the heights. It should be quite rare though (only happens at the end of Sync), so I'm not sure if it's worth adding a new debug log category for indexes just for this.
There was a problem hiding this comment.
ACK 5af4d6e
I ran the experiments I suggested in my previous Concept ACK.
The skip path does trigger, but the loss window is bounded by 30s, which is negligible compared to a multi-day IBD, so the changes are correct.
I wopudl encorague again to add a few more tests:
- Reorg scenario: Verify behavior when the index is on one fork and the flushed chainstate is on another.
- Recovery: Verify the index resumes from the last chainstate flush point. I think will be interesting to verify the corret initialization behaviour of isBlockInFlushedChain
|
@mzumsande There's a silent merge conflict here: |
There was a problem hiding this comment.
ACK 5af4d6e
Would be ok for me to merge as-is but I think particularly @furszy 's comments here and here would be good to address. Maybe some of mine as well but I don't consider them strictly blocking and I would open a follow-up if they are not addressed. I will re-review quickly if you decide to address the open comments.
EDIT: Never mind, I guess you have to push anyway due to the silent merge conflict.
| // Don't commit if the index is ahead of the chainstate's last flushed block | ||
| const CBlockIndex* index_tip = m_best_block_index.load(); | ||
| if (index_tip && !m_chain->isBlockInFlushedChain(index_tip->GetBlockHash(), index_tip->nHeight)) { | ||
| LogInfo("Skipping commit, index is ahead of flushed chainstate"); |
There was a problem hiding this comment.
nit: Also LogInfo on every skip seems a bit excessive. Maybe make this a debug log?
l0rinc
left a comment
There was a problem hiding this comment.
Left a few more comments - given the other comments and the silent merge conflict.
It would also be good to confirm whether the same unsafe restart boundary applies to other indexes using eager write paths, such as BlockFilterIndex.
| // 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(coinstatsindex_no_commit_ahead_of_flush, TestChain100Setup) |
There was a problem hiding this comment.
5af4d6e test: add test to ensure indexes dont commit too early:
This seems like a good example for #35260 - it would help me with the review to know what the state was before the change and which commit changes the assumption and how and where it changes it.
Maybe something like this documenting the state before the fix:
BOOST_FIXTURE_TEST_CASE(coinstatsindex_commits_ahead_of_chainstate_flush, TestChain100Setup)
{
Chainstate& chainstate = Assert(m_node.chainman)->ActiveChainstate();
BlockValidationState state;
auto current_tip{[&] { return Assert(WITH_LOCK(::cs_main, return chainstate.m_chain.Tip())); }};
auto coinsdb_best_is{[&](const CBlockIndex& block) {
LOCK(::cs_main);
BOOST_CHECK(chainstate.CoinsDB().GetBestBlock() == block.GetBlockHash());
}};
auto sync_fresh_index{[&](int expected_height) {
CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB, /*f_memory=*/false, /*f_wipe=*/true};
BOOST_REQUIRE(index.Init());
index.Sync();
BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, expected_height);
index.Stop();
}};
auto read_persisted_index_height{[&] {
CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB};
BOOST_REQUIRE(index.Init());
const int height{index.GetSummary().best_block_height};
index.Stop();
return height;
}};
auto flush_and_commit_index{[&] {
CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB};
BOOST_REQUIRE(index.Init());
index.Sync();
BOOST_REQUIRE(chainstate.FlushStateToDisk(state, FlushStateMode::FORCE_FLUSH));
m_node.validation_signals->SyncWithValidationInterfaceQueue();
index.Stop();
}};
// For one unflushed index tip, check the durable height before and after the chainstate catches up.
auto check_unflushed_index_commit{[&](const CBlockIndex& index_tip, int expected_persisted_height) {
sync_fresh_index(index_tip.nHeight);
BOOST_CHECK_EQUAL(read_persisted_index_height(), expected_persisted_height);
flush_and_commit_index();
BOOST_CHECK_EQUAL(read_persisted_index_height(), index_tip.nHeight);
}};
// Start from a known durable chainstate boundary.
BOOST_REQUIRE(chainstate.FlushStateToDisk(state, FlushStateMode::FORCE_FLUSH));
auto* flushed_tip{current_tip()};
coinsdb_best_is(*flushed_tip);
// Straight-line case: connect one more block without flushing the chainstate.
CreateAndProcessBlock({}, CScript() << OP_TRUE);
auto* unflushed_tip{current_tip()};
BOOST_REQUIRE_GT(unflushed_tip->nHeight, flushed_tip->nHeight);
coinsdb_best_is(*flushed_tip);
check_unflushed_index_commit(*unflushed_tip, /*expected_persisted_height=*/unflushed_tip->nHeight); // TODO: Persists ahead of the flushed chainstate.
auto* reorg_flushed_tip{current_tip()};
coinsdb_best_is(*reorg_flushed_tip);
// Fork case: leave the flushed chainstate behind, then index a competing unflushed tip.
BOOST_REQUIRE(chainstate.InvalidateBlock(state, reorg_flushed_tip));
NodeClockContext clock_ctx{};
clock_ctx += 1s;
CreateAndProcessBlock({}, CScript() << OP_TRUE);
clock_ctx += 1s;
CreateAndProcessBlock({}, CScript() << OP_TRUE);
auto* fork_tip{current_tip()};
BOOST_REQUIRE_EQUAL(fork_tip->nHeight, reorg_flushed_tip->nHeight + 1);
BOOST_CHECK(fork_tip->GetAncestor(reorg_flushed_tip->nHeight) != reorg_flushed_tip);
coinsdb_best_is(*reorg_flushed_tip);
check_unflushed_index_commit(*fork_tip, /*expected_persisted_height=*/fork_tip->nHeight); // TODO: Persists a fork from the flushed chainstate.
}so that the commit fixing it would have a minimal diff of what it affects exactly:
diff --git a/src/test/coinstatsindex_tests.cpp b/src/test/coinstatsindex_tests.cpp
--- a/src/test/coinstatsindex_tests.cpp (revision 7177975c1d50fd4e0164fa6093a06b68302f0115)
+++ b/src/test/coinstatsindex_tests.cpp (revision e8197142a66c98e72d2f8c6f138f4e29a9d83b94)
@@ -124,7 +124,7 @@
}
}
-BOOST_FIXTURE_TEST_CASE(coinstatsindex_commits_ahead_of_chainstate_flush, TestChain100Setup)
+BOOST_FIXTURE_TEST_CASE(coinstatsindex_commit_is_limited_by_chainstate_flush, TestChain100Setup)
{
Chainstate& chainstate = Assert(m_node.chainman)->ActiveChainstate();
BlockValidationState state;
@@ -175,7 +175,7 @@
BOOST_REQUIRE_GT(unflushed_tip->nHeight, flushed_tip->nHeight);
coinsdb_best_is(*flushed_tip);
- check_unflushed_index_commit(*unflushed_tip, /*expected_persisted_height=*/unflushed_tip->nHeight); // TODO: Persists ahead of the flushed chainstate.
+ check_unflushed_index_commit(*unflushed_tip, /*expected_persisted_height=*/0);
auto* reorg_flushed_tip{current_tip()};
coinsdb_best_is(*reorg_flushed_tip);
@@ -193,7 +193,7 @@
BOOST_CHECK(fork_tip->GetAncestor(reorg_flushed_tip->nHeight) != reorg_flushed_tip);
coinsdb_best_is(*reorg_flushed_tip);
- check_unflushed_index_commit(*fork_tip, /*expected_persisted_height=*/fork_tip->nHeight); // TODO: Persists a fork from the flushed chainstate.
+ check_unflushed_index_commit(*fork_tip, /*expected_persisted_height=*/0);
}
BOOST_AUTO_TEST_SUITE_END()There was a problem hiding this comment.
I have tried this approach in the past, I prefer to add the full test (which can be cherry-picked on master to see the failure). Otherwise, it adds a lot of churn to check every comment etc. if it already made sense before, without the context of the fix. But I did rewrite the unit test, it should be very obvious how it fails on master.
There was a problem hiding this comment.
re: #34897 (comment)
Agree with l0rinc, there is no significant churn here and adding the test first would make the bugfix commit easier to understand, and avoid the need for reviewers to do manual work to verify the test meaningfully checks the changed behavior, when this could be checked by the "test ancestor commits" CI job.
My diff to confirm the test was:
--- b/src/test/coinstatsindex_tests.cpp
+++ a/src/test/coinstatsindex_tests.cpp
@@ -145,7 +145,7 @@ BOOST_FIXTURE_TEST_CASE(coinstatsindex_no_commit_ahead_of_flush, TestChain100Set
{
CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB};
BOOST_REQUIRE(index.Init());
- BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, 100);
+ BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, 0);
index.Stop();
}
@@ -182,7 +182,7 @@ BOOST_FIXTURE_TEST_CASE(coinstatsindex_no_commit_ahead_of_flush, TestChain100Set
{
CoinStatsIndex index{interfaces::MakeChain(m_node), /*n_cache_size=*/1_MiB};
BOOST_REQUIRE(index.Init());
- BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, 101);
+ BOOST_CHECK_EQUAL(index.GetSummary().best_block_height, 100);
index.Stop();
}
}There was a problem hiding this comment.
I have tried this approach in the past, I prefer to add the full test (which can be cherry-picked on master to see the failure). Otherwise, it adds a lot of churn to check every comment etc.
That's exactly what this technique is solving: you don't need to cherry-pick on master, because you're already proving that the fix makes you adjust the test for it to pass. The CI already proved that the previous version passed and that the modified version also passes with the fix - no need to cherry-pick back and forth.
Also, the current regression coverage primarily demonstrates that the index will not commit if it is ahead by height. Could we extend it with a same-height competing block case, so a height-only check would fail and the ancestry requirement is tested directly?
That would make it clear that the gate is not only "height <= flushed height", but "same block or ancestor of the flushed block".
There was a problem hiding this comment.
ok, I changed it. There is still churn: Now I had to separate out comments that refer to the last flushed block, since they make no sense in a commit before it was introduced. Whenever I will add things to the test, I will likely have to resolve conflicts since it's now spread over two commits. Also, ideally, one should not just have a succeeding texts but also add motivating comments in the original test why the behavior is not ideal and should be fixed (and then delete these comments when fixed), which would create even more churn.
So for the future I definitely prefer to have less churn in the git history and instead have reviewers cherry-pick (and, as a frequent reviewer myself, have absolutely no problem doing that).
| if (ok) { | ||
| // Don't commit if the index is ahead of the chainstate's last flushed block | ||
| const CBlockIndex* index_tip = m_best_block_index.load(); | ||
| if (index_tip && !m_chain->isBlockInFlushedChain(index_tip->GetBlockHash(), index_tip->nHeight)) { |
There was a problem hiding this comment.
7933e27 index: Don't commit ahead of the flushed chainstate:
coinstatsindex doesn't seem to be only index with a similar race, see: #34489 (comment)
Can we cover it in this PR?
There was a problem hiding this comment.
All indexes are affected by it because we don't have a special logic for indexes without state. I added it to the coinstatsindex file because that index has state and actually needs to rewind. I think it would make sense to add a base_index tests for generic functionality that affects all indexes (and run the test for all index types, or maybe pick a random one), but I think it would be better to do that in a follow-up because it should affect other existing tests too.
| index.Stop(); | ||
| } | ||
|
|
||
| // Reload the index and verify the committed state. |
There was a problem hiding this comment.
Shouldn't we also test the reorg scenario?
There was a problem hiding this comment.
I hope the updated test covers it.
There was a problem hiding this comment.
Not the same-height reorg, I still think those should be covered (and docs updated)
There was a problem hiding this comment.
Can you explain more? Unless I’m missing something, this doesn’t seem like a practical scenario to me:
- same-height reorgs don’t really happen, outside of artificial situations such a
preciousblock. You reorg to a better chain, and that chain is usually longer. - Even for a reorg scenario to a longer chain, I think that the following would need to happen: The
Sync()thread reaches the tip, then while the index thread pauses in betweenboth a reorg and a flush must happen in the main thread. This seems like a very artificial situation to me timing-wise, but it is also hard to replicate in a unit test, because we cannot just do the reorg before callingLines 228 to 231 in f0da26c
Sync()as we do in the additional block scenario:
If the reorg happens while the index is syncing earlier block the index never notices it (it chooses the next block sync based on the current chain), if a reorg happens afterSync()finished and we are in notification mode we will never flush before also doing the reorg in the index because notifications are processed in order.
| if (ok) { | ||
| // Don't commit if the index is ahead of the chainstate's last flushed block | ||
| const CBlockIndex* index_tip = m_best_block_index.load(); | ||
| if (index_tip && !m_chain->isBlockInFlushedChain(index_tip->GetBlockHash(), index_tip->nHeight)) { |
There was a problem hiding this comment.
7933e27 index: Don't commit ahead of the flushed chainstate:
Since this is basically the only usage of this method, we should be able to tailor it more, to let the chain interface take just the candidate block hash instead of a hash and height pair from the caller:
| if (index_tip && !m_chain->isBlockInFlushedChain(index_tip->GetBlockHash(), index_tip->nHeight)) { | |
| if (!m_chain->isAncestorOfLastFlushedBlock(best_block_index->GetBlockHash())) { |
calling:
bool isAncestorOfLastFlushedBlock(const uint256& block_hash) override
{
LOCK(::cs_main);
const CBlockIndex* block{chainman().m_blockman.LookupBlockIndex(block_hash)};
const CBlockIndex* last_flushed{chainman().ValidatedChainstate().GetLastFlushedBlock()};
return block && last_flushed && last_flushed->GetAncestor(block->nHeight) == block;
}There was a problem hiding this comment.
I took this change, seems like a simpler interface with just one parameter, and the added lookup should not be performance critical since commits are rare.
| @@ -272,7 +272,15 @@ bool 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; | |||
There was a problem hiding this comment.
7933e27 index: Don't commit ahead of the flushed chainstate:
This isn't a "real" nullptr check, so we could probably simplify the structure a bit by something like:
const CBlockIndex* best_block_index{m_best_block_index.load()};
if (!best_block_index) return true;
if (!m_chain->isAncestorOfLastFlushedBlock(best_block_index->GetBlockHash())) {
return true;
}
...There was a problem hiding this comment.
this would change the logic, we currently return false if best_block_index is not set. This relates to above discussion about the (unused) return value.
There was a problem hiding this comment.
Yes, if we remove the return value we could apply this - which would simplify the logic, reuse, and indentation:
diff --git a/src/index/base.cpp b/src/index/base.cpp
index 0be591e7da..3ee70fdd8a 100644
--- a/src/index/base.cpp
+++ b/src/index/base.cpp
@@ -273,31 +273,29 @@ void BaseIndex::Sync()
}
}
-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;
+ const CBlockIndex* index_tip{m_best_block_index.load()};
+ if (!index_tip) return;
+
+ // Don't commit if the index is ahead of the chainstate's last flushed block
+ if (!m_chain->isAncestorOfLastFlushedBlock(index_tip->GetBlockHash())) {
+ LogInfo("Skipping commit, index is ahead of flushed chainstate (index height %d, chain height %d)",
+ index_tip->nHeight, m_chain->getHeight().value_or(-1));
+ return;
+ }
+
+ CDBBatch batch(GetDB());
+ bool ok = CustomCommit(batch);
if (ok) {
- // Don't commit if the index is ahead of the chainstate's last flushed block
- const CBlockIndex* index_tip = m_best_block_index.load();
- if (!m_chain->isAncestorOfLastFlushedBlock(index_tip->GetBlockHash())) {
- LogInfo("Skipping commit, index is ahead of flushed chainstate (index height %d, chain height %d)",
- index_tip->nHeight, m_chain->getHeight().value_or(-1));
- return false;
- }
- CDBBatch batch(GetDB());
- ok = CustomCommit(batch);
- if (ok) {
- GetDB().WriteBestBlock(batch, GetLocator(*m_chain, m_best_block_index.load()->GetBlockHash()));
- GetDB().WriteBatch(batch);
- }
+ GetDB().WriteBestBlock(batch, GetLocator(*m_chain, index_tip->GetBlockHash()));
+ GetDB().WriteBatch(batch);
}
if (!ok) {
LogError("Failed to commit latest %s state", GetName());
- return false;
}
- return true;
}
bool BaseIndex::Rewind(const CBlockIndex* current_tip, const CBlockIndex* new_tip)
diff --git a/src/index/base.h b/src/index/base.h
index 4e4cd603c0..5f67b368ae 100644
--- a/src/index/base.h
+++ b/src/index/base.h
@@ -93,12 +93,11 @@ private:
std::thread m_thread_sync;
CThreadInterrupt m_interrupt;
- /// Write the current index state (eg. chain block locator and subclass-specific items) to disk.
- /// 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.
- /// Returns whether a commit was done.
- bool Commit();
+ /// Try to write the current index state (eg. chain block locator and subclass-specific items) to disk.
+ /// Returns without writing 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 CustomRemove.
bool Rewind(const CBlockIndex* current_tip, const CBlockIndex* new_tip);There was a problem hiding this comment.
I did inline it, but didn't do any further refactors of the function. The suggestion above changes behavior such that we no longer log an error if m_best_block_index is nullptr. That change may be desirable, but I want to keep it out of the scope of this PR.
| // Flush the chainstate (which may refer to block index entries). | ||
| empty_cache ? CoinsTip().Flush() : CoinsTip().Sync(); | ||
| m_last_flushed_block = m_chain.Tip(); |
There was a problem hiding this comment.
489bb4a validation: track last flushed block:
Could this new ChainStateFlushed locator come from the recorded flushed block instead of rereading m_chain.Tip()?
diff --git a/src/validation.cpp b/src/validation.cpp
--- a/src/validation.cpp (revision 30c92e346762ec5d046d81d46d08811ccd9e26b3)
+++ b/src/validation.cpp (revision 2296c4e0101ee7de230478d0dd7323b04a849fe4)
@@ -2831,7 +2831,7 @@
}
if (full_flush_completed && m_chainman.m_options.signals) {
// Update best block in wallet (so we can detect restored wallets).
- m_chainman.m_options.signals->ChainStateFlushed(this->GetRole(), GetLocator(m_chain.Tip()));
+ m_chainman.m_options.signals->ChainStateFlushed(this->GetRole(), GetLocator(Assert(m_last_flushed_block)));
}
} catch (const std::runtime_error& e) {
return FatalError(m_chainman.GetNotifications(), state, strprintf(_("System error while flushing: %s"), e.what()));
mzumsande
left a comment
There was a problem hiding this comment.
Sorry for the delay, I am currently working on addressing all comments and will rebase/push a new version in a few days.
0f13529 to
37849c2
Compare
|
🚧 At least one of the CI tasks failed. HintsTry to run the tests locally, according to the documentation. However, a CI failure may still
Leave a comment here, if you need help tracking down a confusing failure. |
|
Rebased and addressed feedback. Major changes:
|
This will be used to prevent the indexes from flushing their state ahead of the chainstate. Co-authored-by: Lőrinc <pap.lorinc@gmail.com>
Since it unused in the current code.
Otherwise, if the node has an unclean restart, indexes with state (coinstatsindex) couldn't reorg to the last flushed tip and would be corrupted. Also updates documentation of Commit() - the locator functionality isn't used, so the previous text was wrong: We must have the best block in our block index after a restart. Co-authored-by: Fabian Jahr <fjahr@protonmail.com>
In DisconnectBlock(), we can call FlushStateToDisk after updating the coins but before changing the tip, which is still at the disconnected block. This means that the ChainStateFlushed signal would have the wrong block in the locator. Also remove an outdated comment - the wallet doesn't use ChainStateFlushed anymore, currently only indexes do. Co-authored-by: Lőrinc <pap.lorinc@gmail.com>
5ca05a6 to
e9ed898
Compare
| @@ -0,0 +1,62 @@ | |||
| // Copyright (c) 2020-present The Bitcoin Core developers | |||
There was a problem hiding this comment.
nit
| // Copyright (c) 2020-present The Bitcoin Core developers | |
| // Copyright (c) The Bitcoin Core developers |
|
ACK e9ed898 |
…ed chainstate c5471a0 validation: Don't use m_chain.Tip() in FlushStateToDisk (Martin Zumsande) 904cb97 index: Don't commit ahead of the flushed chainstate (Martin Zumsande) e3468cf index: Remove return value from Commit() (Martin Zumsande) ae5ca14 validation: track last flushed block (Martin Zumsande) b5a10fc test: add test for index commits ahead of the last flushed block (Martin Zumsande) Pull request description: If indexes commit their data ahead of the flushed chainstate, and there is an unclean shutdown, the index will be corrupted. This is especially the case for the coinstatsindex, which has state (the muhash) which can't easily be rolled back without access to the blocks. This was only partly fixed in #33212 (for reorg scenarios) but could still happen during initial sync. Fix this more thoroughly by having the node keep track of the last flushed block, and skipping index commits if the current block of the index is not an ancestor of the node's last flushed block (similar to the suggestion by stickies-v in bitcoin/bitcoin#33212 (review). Fixes #33208 Fixes #34261 ACKs for top commit: achow101: ACK c5471a0 sedited: Re-ACK c5471a0 fjahr: re-ACK c5471a0 Tree-SHA512: 7f4dc6fb942d6726587eb75dece24c79c679d8630320502aca9fa2d2b03b1d25999cd11255ec20344ebbb8985552747e2554e2557b9d2ad0c75db71652d615ab
|
Added a follow-up: #35714 makes the new |
… flushed chainstate Track the chainstate's last flushed block and skip a BaseIndex commit when the index tip is ahead of it, so an unclean shutdown can't leave a stateful index (e.g. coinstatsindex/txindex) persisted ahead of a chainstate it can no longer roll back to. Verbatim port of upstream Bitcoin Core PR 34897.
…4e48e968a 114e48e968a kernel: Add script tracer 54e1a95a12e Merge bitcoin/bitcoin#35719: ci: disable Qt build in OpenBSD cross job b0e09511586 ci: disable Qt build in OpenBSD cross job 734c34bafda Merge bitcoin/bitcoin#35427: depends: Build `qt` and `qrencode` packages on OpenBSD ee61b11a9e7 Merge bitcoin/bitcoin#35200: node: smooth oversized `dbcache` warnings e3554bf361f Merge bitcoin/bitcoin#35579: wallet: reserve walletrescan before checking wallet is at the tip 11ae4265522 Merge bitcoin/bitcoin#35715: cmake: Fix WITH_EXTERNAL_LIBMULTIPROCESS + BUILD_FUZZ_BINARY e544413c0de Merge bitcoin/bitcoin#32763: wallet: Replace CWalletTx::mapValue and vOrderForm with explicit class members fe1cb6e40d7 Merge bitcoin/bitcoin#35690: wallet: Introduce WalletError with machine-readable error code 441f3114f57 Merge bitcoin/bitcoin#35659: Clarify supported *BSD releases and drop outdated workarounds 0399df827c6 Merge bitcoin/bitcoin#35708: depends: capnp 1.5.0 1ab1fdd4696 Merge bitcoin/bitcoin#35705: bench: replace CreateMockableWalletDatabase with MakeInMemoryWalletDatabase db35b9238fc ipc # build: Fix fuzz target CMakeLists.txt for external libmultiprocess d18fec892e2 Merge bitcoin/bitcoin#35698: doc: Update enum class constant naming style guide a2e4cd7ad2a depends: capnp 1.5.0 7508ac319d9 bench: replace CreateMockableWalletDatabase with MakeInMemoryWalletDatabase 907e284e303 Merge bitcoin/bitcoin#35701: test: Remove `mock_process.cpp` c8459b6bdcd Merge bitcoin/bitcoin#35568: txospenderindex: disable bloom filters to optimize disk usage 63c5f9d22c0 test: Remove `mock_process.cpp` ef101b04a8d Merge bitcoin/bitcoin#35655: wallet: Use in-memory SQLite for temporary wallet in exportwatchonlywallet b6becf3534c Merge bitcoin/bitcoin#35684: Update libmultiprocess subtree to add `max_connections` option 930f25050f7 Merge bitcoin/bitcoin#35700: doc: archive release notes for v29.4 9b2b3f4ec6f doc: archive release notes for v29.4 297fd1489bb Merge bitcoin/bitcoin#35412: ci: add NetBSD Clang cross job e314869066b Merge bitcoin/bitcoin#35695: Remove myself as security contact e3d67a5eae5 Merge bitcoin/bitcoin#35691: chainparams: delete my DNS seed a8223bb4e62 wallet: Introduce WalletError with machine-readable error code fad5809cb92 doc: Update enum class constant naming style guide 7d8137c1417 Merge bitcoin/bitcoin#34897: indexes: Don't commit ahead of the flushed chainstate 629df81e4c6 Remove myself as security contact d164a043426 node: smooth oversized `-dbcache` warnings d9080639804 chainparams: delete my DNS seed e9ed898a0da validation: Don't use m_chain.Tip() in FlushStateToDisk 3679f1ecf5e index: Don't commit ahead of the flushed chainstate 65735728a5a index: Remove return value from Commit() 09c06960c6a validation: track last flushed block 13c02b5466d test: add test for index commits ahead of the last flushed block c43b7a1115f ci: add netBSD cross CI job 699c21aea47 depends: add netbsd_LDFLAGS 777d23f25c7 test: add regression test for in-memory SQLiteDatabase reopen d1e7f8c986f wallet: use in-memory SQLite for temporary wallet in exportwatchonlywallet ee43743f126 wallet: store m_additional_flags in SQLiteDatabase to fix reopen path a9d1b652f32 Merge commit '707d0ded84563386f770ec17970834c65f8fa938' into pr/subtree-12 707d0ded845 Squashed 'src/ipc/libmultiprocess/' changes from 16bf05dea02..28e056576a3 2bab6bc73f2 refactor: Drop support for FreeBSD < 14 91b5c8a07c6 refactor: Remove FreeBSD-specific workaround 56701ff6d5c doc: Clarify supported *BSD releases 6d0ea4cf5bd doc: add release notes a2b1c86903a txospenderindex: disable bloom filters to optimize disk usage fed3cf6f0ed wallet: Replace CWalletTx's vOrderForm with specific fields 4f8823e8e11 wallet: Drop vOrderForm from CommitTransaction 9e62e4b1f34 test: slow down rescaning process a2b0bfcd854 wallet: Drop mapValue from CWalletTx cb99864c91c wallet: Throw if unknown entry is found in mapValue 98d5cdae663 wallet: Make CWalletTx "replaces_txid" and "replaced_by_txid" member variables 7ef8a6efc2a wallet: Make CWalletTx "comment" and "to" member variables 2155e913d3e wallet: Make CWalletTx "from" and "message" member variables c6ba98dcc8a wallet: Drop mapValue from CommitTransaction 00abb174a80 wallet: Pass comment and comment_to to CommitTransaction 1a219a37a21 wallet: Pass replaces_txid to CommitTransaction outside of mapValue 336f5a738b3 wallet: reserve walletrescan before checking wallet is at the tip a54ec373a69 depends: Build `qt` and `qrencode` packages for OpenBSD hosts git-subtree-dir: libbitcoinkernel-sys/bitcoin git-subtree-split: 114e48e968a087f74c1ab611ac2a31a9266812e1
sounds good, I'll review soon. I am working on a test follow-up (moving more stuff to baseindex_tests and executing the tests for different indexes). |
If indexes commit their data ahead of the flushed chainstate, and there is an unclean shutdown, the index will be corrupted. This is especially the case for the coinstatsindex, which has state (the muhash) which can't easily be rolled back without access to the blocks. This was only partly fixed in #33212 (for reorg scenarios) but could still happen during initial sync.
Fix this more thoroughly by having the node keep track of the last flushed block, and skipping index commits if the current block of the index is not an ancestor of the node's last flushed block (similar to the suggestion by stickies-v in #33212 (review).
Fixes #33208
Fixes #34261