-
Notifications
You must be signed in to change notification settings - Fork 37.7k
p2p: For assumeutxo, download snapshot chain before background chain #29519
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
p2p: For assumeutxo, download snapshot chain before background chain #29519
Conversation
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code CoverageFor detailed information about the code coverage, see the test coverage report. ReviewsSee the guideline for information on the review process.
If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update. |
Note that given that 27 will not ship with mainnet assumeutxo, this isn't a blocker, and a fix can still be backported to 27.x if deemed necessary. |
Concept ACK ac547ea looks reasonable, but it would be nice to somehow add a test for it. What happens once we've reached the tip and need more peers for background sync? And once background sync is done, is it updated to the tip again? Would it make sense to introduce |
I tested this syncing on signet, but I'll think about a functional test; I imagine that it would be hard to prevent race conditions in a functional test because we'd:
It might be possible though if that peer was a
We have this logic in |
I can see how it would be difficult to test, indeed. I encountered this scenario while testing #29370 on signet. All my peers were only downloading the background state. I only got headers for the tip chain, but it didn't progress. The workaround there was to turn the network off and on, which is consistent with your observation that only existing peers are affected. utACK ac547ea |
The code and reasoning look good to me and I have been trying to write a test but I am currently unable to reproduce the behavior described before the change, i.e. to make the test fail on master. Here is the test so far: fjahr@478fb28 This now does fail on master but only because it also checks for the debug log @mzumsande do you have an idea why this doesn't reproduce the behavior you have seen on master? |
I tested your branch, and I see the same - this is really interesting, and it works differently than I thought when I opened the PR! I think the following happens in bitcoin/src/net_processing.cpp Line 1430 in 2102c97
But due to the lines bitcoin/src/net_processing.cpp Lines 1497 to 1502 in 2102c97
we never ask for the blocks below the snapshot block here: Even though we don't have their data, they are now in the active chain, so they are skipped instead. In the test, we immediately ask for the blocks after the snapshot instead, which is exactly the desired behavior. So why did I observe a different behavior on signet? If this wasn't the case, we'd request nothing in So I think what would actually happen on mainnet and signet is that each time we can process a block request with an existing peer, we'd move Still, I think that this behavior is not great, and the fix is an improvement - not only because we start downloading the snapshot chain immediately, we also do much less iterating in |
tACK ac547ea I reproduced the issue on current master with signet and confirmed that it is fixed with the change here (three times actually with slightly different logs). Based on reading the code and some custom logging I agree with @mzumsande that his description above is what is indeed happening. Based on this I am fine to add this without a test. |
@ryanofsky would be really great to get your review here when you get the chance :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for not seeing this earlier. I think I found a potential problem with this change, so I left a comment below and would be curious to find out more.
src/net_processing.cpp
Outdated
if (state->pindexLastCommonBlock == nullptr) { | ||
std::optional<int> snapshot_height{m_chainman.GetSnapshotBaseHeight()}; | ||
if (state->pindexLastCommonBlock == nullptr || | ||
(snapshot_height.has_value() && state->pindexLastCommonBlock->nHeight < snapshot_height.value())) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In commit "p2p: For assumeutxo, download snapshot chain before background chain" (ac547ea)
It seems like the pindexLastCommonBlock->nHeight < snapshot_height
check is not strict enough because the snapshot block might not necessarily be an ancestor the peer's best known block.
In this case, the pindexLastCommonBlock
assignments on line 1406 and 1411 will reset it back to the fork point between the two nodes each time FindNextBlocksToDownload
is called. And if the peer's best block is more than BLOCK_DOWNLOAD_WINDOW
blocks away from the current chain, the node will never be able to download enough blocks to reach it and validate it. This means if the snapshot is not on the best chain, the node may be unable to sync to a better chain.
Suggestion to fix would be:
--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -1487,12 +1487,14 @@ void PeerManagerImpl::FindNextBlocksToDownload(const Peer& peer, unsigned int co
return;
}
- std::optional<int> snapshot_height{m_chainman.GetSnapshotBaseHeight()};
+ // If pindexLastCommonBlock has not been set yet, set it now. Also reset it
+ // if an assumetxo snapshot has been loaded and it is an ancestor of the
+ // snapshot, so only blocks after the snapshot will be downloaded.
+ const CBlockIndex* snap_base{m_chainman.GetSnapshotBaseBlock()};
if (state->pindexLastCommonBlock == nullptr ||
- (snapshot_height.has_value() && state->pindexLastCommonBlock->nHeight < snapshot_height.value())) {
+ (snap_base && snap_base->GetAncestor(state->pindexLastCommonBlock->nHeight) == state->pindexLastCommonBlock)) {
// Bootstrap quickly by guessing a parent of our best tip is the forking point.
// Guessing wrong in either direction is not a problem.
- // Also applies after loading a snapshot, when we want to prioritise loading the snapshot chain and therefore need to skip ahead.
state->pindexLastCommonBlock = m_chainman.ActiveChain()[std::min(state->pindexBestKnownBlock->nHeight, m_chainman.ActiveChain().Height())];
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This means if the snapshot is not on the best chain, the node may be unable to sync to a better chain.
I've thought about this for a while, and I'm not sure if that problem is really being fixed by your suggestion for the following (slightly philosophical) reasons:
- Accepting a snapshot means that we've created a new chainstate with its Active Tip being set to that block.
- That is a commitment, we can't just reorg away from it to another chain that doesn't contain the snapshot block, even if such a chain existed - at least not until the background sync has finished (we'd need the full blocks below the snapshot), and once that has happened, the snapshot chainstate gets deleted.
- If all of our peers are on a better chain not containing the Snapshot, it seems to me that we are in a hopeless situation anyway: With the current patch we might download no blocks from those peers, with your suggestion we'd download useless blocks that can't be connected - I'm not sure why one is better than the other.
So I think that the premise that we could get into this situation should be rejected:
If a valid snapshot was provided and put into a release, it should only be possible if there was a massive reorg of multiple thousands of blocks (and we'd also need to have accepted header chains ending in both chaintips from different peers). I'd say that in this hypothetical situation, AssumeUtxo is not at the top of the list of our concerns - bitcoin would have far bigger problems.
Without a massive reorg, there is the alternative that the snapshot provided was bad somehow. But then (apart from the fact that it shouldn't have been hardcoded into chainparams), it also shouldn't have been loaded in the first place because its header should not have been accepted to our block index with our header DoS protections.
So all in all I think that once we've accepted a snapshot, we've made a commitment and have to treat it as part of the best chain, at least until background sync has finished.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
re: #29519 (comment)
Good points. My suggested change was premised on the false assumption that the node would be able to seamlessly reorg to the most-work chain. But that's not true because of missing undo data.
Your philosophical point is interesting. I believe that:
- Loading snapshots should not affect how nodes come to consensus, it should only affect what order they download blocks, and help them compute the latest UTXO sets sooner.
And you seem to be suggesting:
- Loading a snapshot should force a node to only consider chains that include the snapshot block and ignore other chains, even if they have more work.
On this PR more specifically:
When I made my suggested change, I was forgetting that we don't have any undo data for the snapshot block and blocks before it, so unfortunately it is not possible for a node to follow the most-work chain when a snapshot is loaded until all the preceding blocks are downloaded.
Given this fact, I think the best thing FindNextBlocksToDownload can do when an unvalidated snapshot is loaded is to ignore peers on other chains not containing the snapshot block, until it actually has the blocks and undo data that would be needed to validate other chains. I think the current PR is not exactly doing this though. It looks like it will still download up to BLOCK_DOWNLOAD_WINDOW useless blocks on the peer's chain, starting at the fork point between the two chains, which is not great. It will also not download later blocks after snapshot is validated, when undo data needed to enable a re-org becomes available.
On the broader approach to downloading blocks when a snapshot is loaded:
I'd agree now that the only sensible block downloading behavior for an unvalidated snapshot chainstate is to only download blocks after the snapshot (due to lack of undo data before then) and ignore any peers whose best blocks don't descend from the snapshot block.
But it is less clear which blocks should be downloaded and attached to the older chainstate, and whether it should target the most-work block or the snapshot block. Since d43a1f1 from #27746 it targets the snapshot block, but was not doing that originally. Comparing the two alternatives:
-
Targeting the most-work block seems like a purer approach that would probably simplify the code and remove special cases, and would also have the benefit of the node syncing to the most-work chain faster, with less wasted work if it the chain turns out to be valid.
-
Targeting the snapshot block is also very reasonable, and is what the code is doing currently, and guarantees the snapshot will be validated even if it is not used, and could be a practical defense against eclipse attacks, or fork chains that appear to have more work but turn out to be invalid.
On whether we should care about any of this:
I don't think we need to care about these design choices too much, since everything above is referring to a case we don't expect to encounter, where there is a possibly-valid chain of blocks, not including the snapshot block, that has more work than any known chain including the snapshot block. The only time when we should see this is when there is a major fork with a lot of work behind it.
However, I don't think "the premise that we could get into this situation should be rejected." That seems like it is going too far. We should put some thought into how code will behavior in presence for forks for practical reasons, and also to inform design of the code and to write it in a clean way that isn't full of assumptions and special cases.
I do think, as long as we are hardcoding snapshot hashes in the source code, we can reject premise that snapshot chainstate turns out to be invalid, and just refuse to run in that case. But the case where the snapshot is valid, but the snapshot block is not on the most-work chain seems different and worth thinking about.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And you seem to be suggesting:
Loading a snapshot should force a node to only consider chains that include the snapshot block and ignore other chains, even if they have more work.
This was meant more as a description of the status quo than a description of the ideal world. I think that with today's logic we don't really have another choice but to do so - temporarily until the background sync has finished.
but the snapshot block is not on the most-work chain seems different and worth thinking about
I agree with that.Some possible ways to deal with that:
1.) Tighten the criteria for snapshot acceptance: We could reject loading the snapshot if the snapshot block is not a predecessor of m_best_header
. Currently, we only check that it has more work than the ActiveTip. This is easy to implement, though it doesn't cover the case where we only learn about the better chain after the snapshot has been loaded.
2.) After a snapshot is accepted and we detect that the snapshot is no longer on the most-work chain we know of, we could abort syncing the snapshot, delete the chainstate and go back to normal sync mode. This sounds more complicated to implement, and I'm not convinced it's worth it.
It looks like it will still download up to BLOCK_DOWNLOAD_WINDOW useless blocks on the peer's chain, starting at the fork point between the two chains, which is not great.
That's true. I will try to adjust the PR to deal with that. Since I find the logic in FindNextBlocksToDownload
/ TryDownloadingHistoricalBlocks
/ FindNextBlocks
rather hard to understand, I would like to reproduce the situation in a test (at least locally, sounds like timing issues could make this hard to include in a PR) to be more confident that this is really how it works, which could take a few days.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have now added a first commit that avoids downloading blocks not on the snapshot chain completely.
Local testing revealed that not only are we unable to reorg to a more-work chain, we also won't fail gracefully: On master, we would still download the blocks and attempt to reorg, but then the node would just crash.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code review ACK ac547ea. This is an improvement over the status quo in the normal case, though it seems like it introduces some odd behavior if snapshot block is not on the most work chain, as described in other comments. I think it would be nice to clean the code up more and clarify what is trying to do, instead of merging this change as it is. But it would also be ok to merge in its current form, if you prefer that.
I will look into improving this (see thread above) in the next days, so hold off merging please. |
ac547ea
to
e977c69
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tACK e977c69
I reviewed the new changes and re-tested the behavior on Signet.
I mostly agree with @mzumsande above and will post some more thoughts on #30288 rather than here since the conversation seems to have moved over there.
So, since I agree this is not something we should encounter this is not a blocker, but just to confirm my understanding: with the current state of the change, if there was an actual other best chain deeper than the snapshot discovered, our node would get stuck because all other nodes would reorg to the best chain and then we would not request blocks from any of our peers, right?
yes, but as you mentioned only if all peers were on that other chain. Whereas the behavior on master would be to download some blocks and then crash with an assert trying to reorg to the new chain - note that this would also happen if the other chain was invalid (for reasons we'd only find out during connecting the blocks) and even if there was just a single peer feeding us that chain. |
It may be worth logging something every time we ignore a peer that is on another chain. In case we encounter this (very unlikely) scenario it would help identify the issue faster and it could be an interesting data point alone to know if anyone ever sees this at all. |
In the unlikely event of such a deep reorg and a lack of peers on the old side, I think it's acceptable if the user needs to restart their node in order to follow the new longest headers. |
If the best chain of the peer doesn't include the snapshot block, it is futile to download blocks from this chain, because we couldn't reorg to it. We'd also crash trying to reorg because this scenario is not handled.
After loading a snapshot, pindexLastCommonBlock is usually already set to some block for existing peers. That means we'd continue syncing the background chain from those peers instead of prioritising the snapshot chain, which defeats the purpose of doing assumeutxo in the first place. Only existing peers are affected by this bug.
e977c69
to
49d569c
Compare
re-ACK 49d569c Only trivial changes addressing comments since my last review, per diff: https://github.com/bitcoin/bitcoin/compare/e977c698f97ac9bba30e4e3837f41721841c28c4..49d569cb1fdd62a9da8dff51dccaf4680fe3d0eb |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tACK 49d569c
Stalling fix
I tested (rebased on master) that without the second commit, when loading a testnet3 snapshot, and only having outbound peers, many peers keep downloading background sync blocks. A few newly connected peers do contribute to the tip.
With the second commit synced_blocks
jumps to the snapshot height. Once the tip is reached (takes a while due to the loppocalypse and because the snapshot is a year old), and background sync needs to take over, synced_blocks
does not jump back to the background sync height. However the inflight
blocks are in the expected range for background sync.
Reorg fix
I tested the reorg scenario as well. First I invalidated block 2,870,000 near the tip and CPU mined a couple of blocks (by messing with the timestamp). I then made a snapshot for it which I hardcoded in the chain params.
I then spun up a fresh node, and first connected it to the previous node to get the right header. I then connected it to random nodes to find the real chain. Trying to load the fake snapshot runs into #30320, so I reverted that to simulate the scenario of a reorg that happens during snapshot handling.
With this PR I get the Not downloading blocks from peer
log messages. The sync completely stalls, and their synced_blocks
stays far below the snapshot height. After a few minutes it finds new outbound peers, but for those synced_blocks
and synced_headers
stays -1
.
When I connect to my stale node the tip updates. The background sync then continues. Once that completes (I had to restart due to an unrelated crash) it winds back for a reorg, but fails:
2024-08-07T14:24:12Z [error] ReadBlockFromDisk: OpenBlockFile failed for FlatFilePos(nFile=-1, nPos=0)
2024-08-07T14:24:12Z [error] DisconnectTip(): Failed to read block
2024-08-07T14:24:12Z [error] A fatal internal error occurred, see debug.log for details: Failed to disconnect block.
2024-08-07T14:24:12Z [error] ProcessNewBlock: ActivateBestChain failed (Failed to disconnect block.)
It's a pruned node, but that's not the reason - it simply doesn't have the undo data yet for blocks below the snapshot. Restarting just causes it to stop again. Certainly better than an assert. A followup PR could nuke the chainstate_snapshot
dir in this scenario. Doing so manually makes it continue.
Reorg behaviour before this PR
Without the commits here, once the snapshot is loaded it stays at the snapshot height for a while. Both the slate and real chaintips are headers-only. This is expected stalling behavior.
I then restart the node. The chaintips remain headers-only for a while. Once I manually connect it to my stale node, it immediately syncs the fake tip. It doesn't attempt to reorg.
Worse, when I restart the node it won't do any background sync until I connect it to the node with the fake chaintip. It would only download from that peer.
When background sync finishes I get the same behaviour: it tries to reorg and fails to disconnect block 2,870,000.
Is there another crash you were referring to? The first commit message says:
We'd also crash trying to reorg because this scenario is not handled.
That could be clarified a bit. The scenario still isn't handled, but we fail a bit later - and not with an assert.
Update: I do notice a difference (improvement). Before this PR after I delete the snapshot chainstate, it just syncs from the snapshot height to the tip - completely forgetting that it still needs to do a background sync. I'm a bit puzzled by this difference.
// so downloading blocks from it would be futile. | ||
const CBlockIndex* snap_base{m_chainman.GetSnapshotBaseBlock()}; | ||
if (snap_base && state->pindexBestKnownBlock->GetAncestor(snap_base->nHeight) != snap_base) { | ||
LogDebug(BCLog::NET, "Not downloading blocks from peer=%d, which doesn't have the snapshot block in its best chain.\n", peer.m_id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
7a88551: although this scenario is worthy of LogWarning
, we have to be careful to avoid a log spam attack. So for now LogDebug
is fine.
I didn't mean an assert with that, just a shutdown with a fatal error. I tested this in a functional test, if I recall correctly the following happened: After loading the snapshot, I would connect a peer with a higher-work tip branching off below the snapshot height. On master we'd download blocks from this peer, and as soon as we'd have a an alternative block with more work in |
What you recall sounds similar to what still happens. But it seems this PR makes recovery from that failure easier (just delete the snapshot dir). I just don't fully understand why and it's very time consuming to reproduce on testnet3. |
ACK 49d569c |
…ore background chain 49d569c p2p: For assumeutxo, download snapshot chain before background chain (Martin Zumsande) 7a88551 p2p: Restrict downloading of blocks for snapshot chain (Martin Zumsande) Pull request description: After loading a snapshot, `pindexLastCommonBlock` is usually already set to some block for existing peers. That means we'd continue syncing the background chain from those peers instead of prioritising the snapshot chain, which defeats the purpose of doing assumeutxo in the first place. Only existing peers are affected by this bug. ACKs for top commit: fjahr: re-ACK 49d569c achow101: ACK 49d569c Sjors: tACK 49d569c Tree-SHA512: 0eaebe1c29a8510d5ced57e14c09b128ccb34b491692815291df68bf12e2a15b52b1e7bf8d9f34808904e7f7bc20f70b0ad0f7e14df93bbdf456bd12cc02a5d2
Summary: If the best chain of the peer doesn't include the snapshot block, it is futile to download blocks from this chain, because we couldn't reorg to it. We'd also crash trying to reorg because this scenario is not handled. This is a partial backport of [[bitcoin/bitcoin#29519 | core#29519]] bitcoin/bitcoin@7a88551 Test Plan: `ninja all check-all` Reviewers: #bitcoin_abc, Fabien Reviewed By: #bitcoin_abc, Fabien Differential Revision: https://reviews.bitcoinabc.org/D18455
Summary: After loading a snapshot, pindexLastCommonBlock is usually already set to some block for existing peers. That means we'd continue syncing the background chain from those peers instead of prioritising the snapshot chain, which defeats the purpose of doing assumeutxo in the first place. This is a backport of [[bitcoin/bitcoin#29519 | core#29519]] bitcoin/bitcoin@49d569c Depends on D18455 Test Plan: `ninja all check-all` Reviewers: #bitcoin_abc, Fabien Reviewed By: #bitcoin_abc, Fabien Differential Revision: https://reviews.bitcoinabc.org/D18456
After loading a snapshot,
pindexLastCommonBlock
is usually already set to some block for existing peers. That means we'd continue syncing the background chain from those peers instead of prioritising the snapshot chain, which defeats the purpose of doing assumeutxo in the first place. Only existing peers are affected by this bug.