Skip to content

Conversation

andrewtoth
Copy link
Contributor

@andrewtoth andrewtoth commented Aug 18, 2024

Following up from #28280 (comment), which suggested a revival of #18746.

GetCoin will never return true for a spent entry, so we can safely assume that any entry we fetch will not be spent. This lets us remove the only non-test code path which adds a FRESH-but-not-DIRTY entry to the flagged linked list. This in turn ensures all entries being sent to BatchWrite are DIRTY entries.

A corollary is that all spent coins must be DIRTY. The only time a coin can be spent and not DIRTY is when the CCoinsViewCacheEntry is created with an empty coin. The last commit makes this more clear by checking for freshness if inserted instead of if spent and not DIRTY.

This is a pure refactor removing dead code which handles a non-existent corner case of a FRESH-but-not-DIRTY entry in the CCoinsViewCache or a spent entry in CCoinsViewDB. There is a lot of test code which tries to exercise this corner case, which is also updated in this PR to behave like non-test code.

@DrahtBot
Copy link
Contributor

DrahtBot commented Aug 18, 2024

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/30673.

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK l0rinc

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #32313 (coins: fix cachedCoinsUsage accounting in CCoinsViewCache by l0rinc)

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.

@DrahtBot
Copy link
Contributor

🚧 At least one of the CI tasks failed.
Debug: https://github.com/bitcoin/bitcoin/runs/28917867890

Hints

Make sure to run all tests locally, according to the documentation.

The failure may happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

Copy link
Contributor

@l0rinc l0rinc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code became a lot simpler now - have a few suggestion I'd like us to consider, but I understand if you think it's out of scope.

Once we get to a state that is ACK-worthy, I'll enable hard assertions everywhere and run an IBD and check if it crashes anywhere.

@andrewtoth
Copy link
Contributor Author

Once we get to a state that is ACK-worthy, I'll enable hard assertions everywhere and run an IBD and check if it crashes anywhere.

I think for a change like this, spending time fuzzing would be more valuable. I believe it already runs in debug mode for that so Assumes would trigger crashes.

Copy link
Contributor

@l0rinc l0rinc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dug deeper and added more relevant questions - bear with me if they're outside the scope of this PR.

Copy link
Contributor

@l0rinc l0rinc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unreliability of many of our tests worries me, would you be open to fixing them in a PR before this one?

// we would remove it from this cache and would never flush spentness
// to the parent cache.
// A spent FRESH coin cannot exist in the cache because a FRESH coin
// is simply erased when it is spent.
//
// Re-adding a spent coin can happen in the case of a re-org (the coin
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this still happen during a reorg? The comment here is really scary, do we really need so much context here? I'd prefer a test that fails when the code is wrong instead of a really long description...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it can happen during a reorg. But we know we can't have a spent coin that is not DIRTY, so the comments being updated here are making this not be redundant. When the coin is spent in DisconnectBlock, it is either erased if FRESH or set DIRTY. The only time we would get into this block if the coin is not DIRTY is if it was just inserted.

// DIRTY, then it can be marked FRESH.
fresh = !it->second.IsDirty();
// If the coin doesn't exist in the current cache then it can be marked FRESH.
fresh = inserted;
Copy link
Contributor

@l0rinc l0rinc Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The freshness flag depend on inserted, possible_overwrite and it->second.coin.IsSpent(), assigned and validated in different places.
Could we obviate the freshness flag calculation instead with something like:

bool fresh_flag = inserted && !possible_overwrite ? CCoinsCacheEntry::FRESH : 0;

Either here or in a follow-up we could simplify & modernize the method to something like:

void CCoinsViewCache::AddCoin(const COutPoint &outpoint, Coin&& coin, bool possible_overwrite) {
    assert(!coin.IsSpent());
    if (coin.out.scriptPubKey.IsUnspendable()) return;

    auto [it, inserted] = cacheCoins.try_emplace(outpoint);
    if (!inserted) {
        if (!possible_overwrite && !it->second.coin.IsSpent()) throw std::logic_error("Attempted to overwrite an unspent coin (when possible_overwrite is false)");
        cachedCoinsUsage -= it->second.coin.DynamicMemoryUsage();
    }
    cachedCoinsUsage += coin.DynamicMemoryUsage();
    it->second.coin = std::move(coin);

    bool fresh_flag = inserted && !possible_overwrite ? CCoinsCacheEntry::FRESH : 0;
    it->second.AddFlags(CCoinsCacheEntry::DIRTY | fresh_flag, *it, m_sentinel);

    TRACE5(utxocache, add,
           outpoint.hash.data(),
           (uint32_t) outpoint.n,
           (uint32_t) it->second.coin.nHeight,
           (int64_t) it->second.coin.out.nValue,
           (bool) it->second.coin.IsCoinBase());
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's leave this for a follow-up.

@@ -793,7 +787,7 @@ BOOST_AUTO_TEST_CASE(ccoins_add)
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated: could coins_cache_simulation_testbe moved into a fuzz test - or would it be too difficult to track all those states?.
If you think any of these recommendations can be done in a parallel PR, let me know and I'll do them myself before this is merged

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. It might just be redundant to our current fuzz harness. It's also valuable to have these tests run on every CI.

@@ -311,12 +311,13 @@ void CCoinsViewCache::SanityCheck() const
size_t recomputed_usage = 0;
size_t count_flagged = 0;
for (const auto& [_, entry] : cacheCoins) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me this can also be merged with the related SanityCheck commit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I merged it with aaef2d6 instead.

@l0rinc
Copy link
Contributor

l0rinc commented Jul 6, 2025

This is still important - @andrewtoth, how can I help?

@DrahtBot
Copy link
Contributor

DrahtBot commented Jul 9, 2025

🚧 At least one of the CI tasks failed.
Task TSan, depends, gui: https://github.com/bitcoin/bitcoin/runs/45603902258
LLM reason (✨ experimental): The CI failure is caused by a syntax error in coinscache_sim.cpp, specifically an expected '}' which leads to undeclared identifier errors during compilation.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

There are no code paths which add a non-DIRTY entry to the cursor in
BatchWrite, so we can safely make this a logic error and test for it.

There are no code paths which add a spent and FRESH coin to the cursor
in BatchWrite, so we can safely make this a logic error and test for it.
It is no longer possible to get non-DIRTY entries in BatchWrite
It is not possible for an entry to be FRESH if not already DIRTY.
A spent coins must be DIRTY, so remove
references to spent but not DIRTY coins.
The only way a spent coin can be not DIRTY
is when creating the CCoinsCacheEntry with
an empty coin. This can be made more clear
by setting fresh if inserted, instead of
checking if an unspent coin is not DIRTY.
@andrewtoth
Copy link
Contributor Author

@l0rinc rebased. Thank you for your reviews! I have tried to address all your comments. There are some older comments that I'm unsure are still relevant. Please let me know if there is still anything outstanding that needs to be addressed.

It is not possible to have a FRESH CCoinsCacheEntry that is not also
DIRTY. Test code uses the SetFresh method out of order to simulate
entries that are FRESH but not DIRTY. By removing the method entirely
we can simplify the test code and make the production code easier to
understand.
@andrewtoth andrewtoth force-pushed the no-spent-and-fresh branch from 5f4095f to fd2338d Compare July 10, 2025 18:29
Copy link
Contributor

@l0rinc l0rinc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The src/coins.cpp prod changes are a bit scary - while it's a very important change, based on the lack of enthusiasm by others, we may want to separate the tests and small refactors into PRs that will get us closer to this.

One could remove the invalid test states - without removing the invalid production code states, just potentially adding TODOs there in case it's missing.
The SetFresh and flag removal could be next and if all of those are merged, we can continue with the scary FetchCoin, AddCoin, BatchWrite and Uncache.

Left a few new comments quickly, let me know what you think.

@@ -148,14 +146,13 @@ class CoinsViewBottom final : public CCoinsView
public:
std::optional<Coin> GetCoin(const COutPoint& outpoint) const final
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the class is already final + other overrides specify that explicitly for clarity

Suggested change
std::optional<Coin> GetCoin(const COutPoint& outpoint) const final
std::optional<Coin> GetCoin(const COutPoint& outpoint) const override

and

    bool HaveCoin(const COutPoint& outpoint) const override

And most other such methods in the file - but I understand if you'd prefer to do in a follow-up instead

Comment on lines +170 to +173
static void SetDirty(CoinsCachePair& pair, CoinsCachePair& sentinel, bool fresh = false) noexcept
{
AddFlags(fresh ? FRESH | DIRTY : DIRTY, pair, sentinel);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's only called here, we could inline and simplify it now:

Suggested change
static void SetDirty(CoinsCachePair& pair, CoinsCachePair& sentinel, bool fresh = false) noexcept
{
AddFlags(fresh ? FRESH | DIRTY : DIRTY, pair, sentinel);
}
static void SetDirty(CoinsCachePair& pair, CoinsCachePair& sentinel, bool fresh = false) noexcept
{
if (!pair.second.m_flags) {
Assume(!pair.second.m_prev && !pair.second.m_next);
pair.second.m_prev = sentinel.second.m_prev;
pair.second.m_next = &sentinel;
sentinel.second.m_prev = &pair;
pair.second.m_prev->second.m_next = &pair;
}
Assume(pair.second.m_prev && pair.second.m_next);
pair.second.m_flags |= fresh ? FRESH | DIRTY : DIRTY;
}

Not sure, but do the flags still make sense after this or would a bool is_fresh also suffice? The dirtiness can be deduced from the m_next/m_prev pair so the only unknown is the freshness which doesn't necessitate a flag anymore - right?

@@ -188,17 +183,15 @@ struct CCoinsCacheEntry
bool IsDirty() const noexcept { return m_flags & DIRTY; }
bool IsFresh() const noexcept { return m_flags & FRESH; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't have Fresh anymore, only IsDirtyAndFresh, right?

Suggested change
bool IsFresh() const noexcept { return m_flags & FRESH; }
bool IsDirtyAndFresh() const noexcept
{
const bool is_fresh = m_flags & FRESH;
Assume(IsDirty() || !is_fresh);
return is_fresh;
}

return it->second; // TODO spent coins shouldn't be returned
}
}
if (auto it{map_.find(outpoint)}; it != map_.end() && !it->second.IsSpent()) return it->second;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the lack of reviews, we may want to split out the test cleanups (removing invalid states) and refactors to a separate PR, where this will be a tracking PR for where we want to get to in safer/smaller steps

CCoinsCacheEntry::SetFresh(n2, sentinel);
BOOST_CHECK(n2.second.IsFresh() && !n2.second.IsDirty());
// Check that setting DIRTY and FRESH on new node inserts it after n1
CCoinsCacheEntry::SetDirty(n2, sentinel, true);
Copy link
Contributor

@l0rinc l0rinc Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
CCoinsCacheEntry::SetDirty(n2, sentinel, true);
CCoinsCacheEntry::SetDirty(n2, sentinel, /*fresh=*/true);

return std::nullopt;
}

bool HaveCoin(const COutPoint& outpoint) const final
{
return m_data.count(outpoint);
return GetCoin(outpoint).has_value();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should be able to do this safely in a prefactor PR regardless of the other changes

@andrewtoth
Copy link
Contributor Author

Closing in favor of #33018.

@andrewtoth andrewtoth closed this Jul 19, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants