diff --git a/blockchain/chain.go b/blockchain/chain.go index 3c22a9aef3..700a6f5442 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -143,7 +143,7 @@ type BlockChain struct { sigCache *txscript.SigCache indexManager indexers.IndexManager interrupt <-chan struct{} - utxoCache *UtxoCache + utxoCache UtxoCacher // subsidyCache is the cache that provides quick lookup of subsidy // values. @@ -2152,7 +2152,7 @@ type Config struct { // the database directly. // // This field is required. - UtxoCache *UtxoCache + UtxoCache UtxoCacher } // New returns a BlockChain instance using the provided configuration details. diff --git a/blockchain/chainio.go b/blockchain/chainio.go index bd7fffaf24..ac628ce9e5 100644 --- a/blockchain/chainio.go +++ b/blockchain/chainio.go @@ -1931,7 +1931,7 @@ func (b *BlockChain) initChainState(ctx context.Context) error { // Initialize the utxo cache to ensure that the state of the utxo set is // caught up to the tip of the best chain. - return b.InitUtxoCache(tip) + return b.utxoCache.Initialize(b, tip) } // dbFetchBlockByNode uses an existing database transaction to retrieve the raw diff --git a/blockchain/common_test.go b/blockchain/common_test.go index 60954e44be..7c1e939789 100644 --- a/blockchain/common_test.go +++ b/blockchain/common_test.go @@ -15,6 +15,7 @@ import ( "math" mrand "math/rand" "os" + "reflect" "testing" "time" @@ -754,6 +755,34 @@ func (g *chaingenHarness) ExpectTip(tipName string) { } } +// ExpectUtxoSetState expects the provided block to be the last flushed block in +// the utxo set state in the database. +func (g *chaingenHarness) ExpectUtxoSetState(blockName string) { + g.t.Helper() + + // Fetch the utxo set state from the database. + var gotState *utxoSetState + err := g.chain.db.View(func(dbTx database.Tx) error { + var err error + gotState, err = dbFetchUtxoSetState(dbTx) + return err + }) + if err != nil { + g.t.Fatalf("unexpected error fetching utxo set state: %v", err) + } + + // Validate that the state matches the expected state. + block := g.BlockByName(blockName) + wantState := &utxoSetState{ + lastFlushHeight: block.Header.Height, + lastFlushHash: block.BlockHash(), + } + if !reflect.DeepEqual(gotState, wantState) { + g.t.Fatalf("mismatched utxo set state:\nwant: %+v\n got: %+v\n", wantState, + gotState) + } +} + // AcceptedToSideChainWithExpectedTip expects the tip block associated with the // generator to be accepted to a side chain, but the current best chain tip to // be the provided value. diff --git a/blockchain/utxocache.go b/blockchain/utxocache.go index 5a50b91cc1..1b7236c8a3 100644 --- a/blockchain/utxocache.go +++ b/blockchain/utxocache.go @@ -58,6 +58,49 @@ const ( periodicFlushInterval = time.Minute * 2 ) +// UtxoCacher represents a utxo cache that sits on top of the utxo set database. +// +// The interface contract requires that all of these methods are safe for +// concurrent access. +type UtxoCacher interface { + // Commit updates the cache based on the state of each entry in the provided + // view. + // + // All entries in the provided view that are marked as modified and spent are + // removed from the view. Additionally, all entries that are added to the + // cache are removed from the provided view. + Commit(view *UtxoViewpoint) error + + // FetchEntries adds the requested transaction outputs to the provided view. + // It first checks the cache for each output, and if an output does not exist + // in the cache, it will fetch it from the database. + // + // Upon completion of this function, the view will contain an entry for each + // requested outpoint. Spent outputs, or those which otherwise don't exist, + // will result in a nil entry in the view. + FetchEntries(filteredSet viewFilteredSet, view *UtxoViewpoint) error + + // FetchEntry returns the specified transaction output from the utxo set. If + // the output exists in the cache, it is returned immediately. Otherwise, it + // uses an existing database transaction to fetch the output from the + // database, cache it, and return it to the caller. The entry that is + // returned can safely be mutated by the caller without invalidating the + // cache. + // + // When there is no entry for the provided output, nil will be returned for + // both the entry and the error. + FetchEntry(dbTx database.Tx, outpoint wire.OutPoint) (*UtxoEntry, error) + + // Initialize initializes the utxo cache by ensuring that the utxo set is + // caught up to the tip of the best chain. + Initialize(b *BlockChain, tip *blockNode) error + + // MaybeFlush conditionally flushes the cache to the database. A flush can be + // forced by setting the force flush parameter. + MaybeFlush(bestHash *chainhash.Hash, bestHeight uint32, forceFlush bool, + logFlush bool) error +} + // UtxoCache is an unspent transaction output cache that sits on top of the // utxo set database and provides significant runtime performance benefits at // the cost of some additional memory usage. It drastically reduces the amount @@ -128,6 +171,9 @@ type UtxoCache struct { timeNow func() time.Time } +// Ensure UtxoCache implements the UtxoCacher interface. +var _ UtxoCacher = (*UtxoCache)(nil) + // UtxoCacheConfig is a descriptor which specifies the utxo cache instance // configuration. type UtxoCacheConfig struct { @@ -367,8 +413,8 @@ func (c *UtxoCache) FetchEntries(filteredSet viewFilteredSet, view *UtxoViewpoin return err } -// Commit updates all entries in the cache based on the state of each entry in -// the provided view. +// Commit updates the cache based on the state of each entry in the provided +// view. // // All entries in the provided view that are marked as modified and spent are // removed from the view. Additionally, all entries that are added to the cache @@ -607,8 +653,8 @@ func (c *UtxoCache) MaybeFlush(bestHash *chainhash.Hash, bestHeight uint32, return nil } -// InitUtxoCache initializes the utxo cache by ensuring that the utxo set is -// caught up to the tip of the best chain. +// Initialize initializes the utxo cache by ensuring that the utxo set is caught +// up to the tip of the best chain. // // Since the cache is only flushed to the database periodically, the utxo set // may not be caught up to the tip of the best chain. This function catches the @@ -616,13 +662,13 @@ func (c *UtxoCache) MaybeFlush(bestHash *chainhash.Hash, bestHeight uint32, // last flushed to the tip block through the cache. // // This function should only be called during initialization. -func (b *BlockChain) InitUtxoCache(tip *blockNode) error { +func (c *UtxoCache) Initialize(b *BlockChain, tip *blockNode) error { log.Infof("UTXO cache initializing (max size: %d MiB)...", - b.utxoCache.maxSize/1024/1024) + c.maxSize/1024/1024) // Fetch the utxo set state from the database. var state *utxoSetState - err := b.db.View(func(dbTx database.Tx) error { + err := c.db.View(func(dbTx database.Tx) error { var err error state, err = dbFetchUtxoSetState(dbTx) return err @@ -639,7 +685,7 @@ func (b *BlockChain) InitUtxoCache(tip *blockNode) error { lastFlushHeight: uint32(tip.height), lastFlushHash: tip.hash, } - err := b.db.Update(func(dbTx database.Tx) error { + err := c.db.Update(func(dbTx database.Tx) error { return dbPutUtxoSetState(dbTx, state) }) if err != nil { @@ -649,8 +695,8 @@ func (b *BlockChain) InitUtxoCache(tip *blockNode) error { // Set the last flush hash and the last eviction height from the saved state // since that is where we are starting from. - b.utxoCache.lastFlushHash = state.lastFlushHash - b.utxoCache.lastEvictionHeight = state.lastFlushHeight + c.lastFlushHash = state.lastFlushHash + c.lastEvictionHeight = state.lastFlushHeight // If state is already caught up to the tip, return as there is nothing to do. if state.lastFlushHash == tip.hash { @@ -680,7 +726,7 @@ func (b *BlockChain) InitUtxoCache(tip *blockNode) error { // disconnecting a block, this will occur very infrequently. In the typical // catchup case, the fork node will be the last flushed node itself and this // loop will be skipped. - view := NewUtxoViewpoint(b.utxoCache) + view := NewUtxoViewpoint(c) view.SetBestHash(&tip.hash) var nextBlockToDetach *dcrutil.Block n := lastFlushedNode @@ -745,7 +791,7 @@ func (b *BlockChain) InitUtxoCache(tip *blockNode) error { // view that are marked as modified and spent are removed from the view. // Additionally, all entries that are added to the cache are removed from // the view. - err = b.utxoCache.Commit(view) + err = c.Commit(view) if err != nil { return err } @@ -753,7 +799,7 @@ func (b *BlockChain) InitUtxoCache(tip *blockNode) error { // Conditionally flush the utxo cache to the database. Don't force flush // since many blocks may be disconnected and connected in quick succession // when initializing. - err = b.utxoCache.MaybeFlush(&n.hash, uint32(n.height), false, true) + err = c.MaybeFlush(&n.hash, uint32(n.height), false, true) if err != nil { return err } @@ -820,14 +866,14 @@ func (b *BlockChain) InitUtxoCache(tip *blockNode) error { // view that are marked as modified and spent are removed from the view. // Additionally, all entries that are added to the cache are removed from // the view. - err = b.utxoCache.Commit(view) + err = c.Commit(view) if err != nil { return err } // Conditionally flush the utxo cache to the database. Don't force flush // since many blocks may be connected in quick succession when initializing. - err = b.utxoCache.MaybeFlush(&n.hash, uint32(n.height), false, true) + err = c.MaybeFlush(&n.hash, uint32(n.height), false, true) if err != nil { return err } diff --git a/blockchain/utxocache_test.go b/blockchain/utxocache_test.go index b7dca91818..d3b860a3f4 100644 --- a/blockchain/utxocache_test.go +++ b/blockchain/utxocache_test.go @@ -13,6 +13,7 @@ import ( "github.com/decred/dcrd/blockchain/stake/v4" "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/database/v2" "github.com/decred/dcrd/wire" ) @@ -147,6 +148,39 @@ func entry85314() *UtxoEntry { } } +// testUtxoCache provides a mock utxo cache by implementing the UtxoCacher +// interface. It allows for toggling flushing on and off to more easily +// simulate various scenarios. +type testUtxoCache struct { + *UtxoCache + disableFlush bool +} + +// MaybeFlush conditionally flushes the cache to the database. If the disable +// flush flag is set on the test utxo cache, this function will return +// immediately without attempting to flush the cache. +func (c *testUtxoCache) MaybeFlush(bestHash *chainhash.Hash, bestHeight uint32, + forceFlush bool, logFlush bool) error { + + // Return immediately if disable flush is set. + if c.disableFlush { + return nil + } + + return c.UtxoCache.MaybeFlush(bestHash, bestHeight, forceFlush, logFlush) +} + +// newTestUtxoCache returns a testUtxoCache instance using the provided +// configuration details. +func newTestUtxoCache(config *UtxoCacheConfig) *testUtxoCache { + return &testUtxoCache{ + UtxoCache: NewUtxoCache(config), + } +} + +// Ensure testUtxoCache implements the UtxoCacher interface. +var _ UtxoCacher = (*testUtxoCache)(nil) + // createTestUtxoDatabase creates a test database with the utxo set bucket. func createTestUtxoDatabase(t *testing.T) database.DB { t.Helper() @@ -1066,3 +1100,197 @@ func TestMaybeFlush(t *testing.T) { } } } + +// TestInitialize validates that the cache recovers properly during +// initialization under a variety of conditions. +func TestInitialize(t *testing.T) { + // Create a test harness initialized with the genesis block as the tip. + params := chaincfg.RegNetParams() + g, teardownFunc := newChaingenHarness(t, params, "initializetest") + defer teardownFunc() + + // --------------------------------------------------------------------------- + // Create some convenience functions to improve test readability. + // --------------------------------------------------------------------------- + + // resetTestUtxoCache replaces the current utxo cache with a new test utxo + // cache and calls initialize on it. This simulates an empty utxo cache that + // gets created and initialized at startup. + resetTestUtxoCache := func() *testUtxoCache { + testUtxoCache := newTestUtxoCache(&UtxoCacheConfig{ + DB: g.chain.db, + MaxSize: 100 * 1024 * 1024, // 100 MiB + }) + g.chain.utxoCache = testUtxoCache + testUtxoCache.Initialize(g.chain, g.chain.bestChain.Tip()) + return testUtxoCache + } + + // forceFlush forces a cache flush to the best chain tip. + forceFlush := func(utxoCache *testUtxoCache) { + tip := g.chain.bestChain.Tip() + utxoCache.MaybeFlush(&tip.hash, uint32(tip.height), true, false) + } + + // --------------------------------------------------------------------------- + // Generate and accept enough blocks to reach stake validation height. + // + // Disable flushing of the cache while advancing the chain. After reaching + // stake validation height, reset the cache and validate that it recovers and + // properly catches up to the tip. + // --------------------------------------------------------------------------- + + // Replace the utxo cache in the test chain with a test utxo cache so that + // flushing can be toggled on and off for testing. + testUtxoCache := resetTestUtxoCache() + + // Validate that the tip and utxo set state are currently at the genesis + // block. + g.ExpectTip("genesis") + g.ExpectUtxoSetState("genesis") + + // Disable flushing and advance the chain. + testUtxoCache.disableFlush = true + g.AdvanceToStakeValidationHeight() + + // Validate that the tip is at stake validation height but the utxo set state + // is still at the genesis block. + g.AssertTipHeight(uint32(params.StakeValidationHeight)) + g.ExpectUtxoSetState("genesis") + + // Reset the cache and force a flush. + testUtxoCache = resetTestUtxoCache() + forceFlush(testUtxoCache) + + // Validate that the utxo cache is now caught up to the tip. + g.ExpectUtxoSetState(g.TipName()) + + // --------------------------------------------------------------------------- + // Create a few blocks to use as a base for the tests below. + // + // ... -> b0 -> b1 + // --------------------------------------------------------------------------- + + outs := g.OldestCoinbaseOuts() + g.NextBlock("b0", &outs[0], outs[1:]) + g.AcceptTipBlock() + + outs = g.OldestCoinbaseOuts() + b1 := g.NextBlock("b1", &outs[0], outs[1:]) + g.AcceptTipBlock() + + // Force a cache flush and validate that the cache is caught up to block b1. + forceFlush(testUtxoCache) + g.ExpectUtxoSetState("b1") + + // --------------------------------------------------------------------------- + // Simulate the following scenario: + // - The utxo cache was last flushed at the tip block + // - A reorg to a side chain is triggered + // - During the reorg, a failure resulting in an unclean shutdown + // occurs after disconnecting a block but before flushing the cache + // and removing the spend journal + // - The resulting state should be: + // - The cache was flushed at b1 + // - The spend journal for b1 was not removed + // - The chain tip is at b1a + // + // last cache flush here + // vvv + // ... -> b0 -> b1 + // \-> b1a + // ^^^ + // new tip + // --------------------------------------------------------------------------- + + // Disable flushing to simulate a failure resulting in the cache not being + // flushed after disconnecting a block. + testUtxoCache.disableFlush = true + + // Save the spend journal entry for b1. The spend journal entry for block b1 + // needs to be restored after the reorg to properly simulate the failure + // scenario described above. + var serialized []byte + b1Hash := b1.BlockHash() + g.chain.db.View(func(dbTx database.Tx) error { + spendBucket := dbTx.Metadata().Bucket(spendJournalBucketName) + serialized = spendBucket.Get(b1Hash[:]) + return nil + }) + if serialized == nil { + t.Fatalf("unable to fetch spend journal entry for block: %v", b1Hash) + } + + // Force a reorg as described above. + g.SetTip("b0") + g.NextBlock("b1a", &outs[0], outs[1:]) + g.AcceptedToSideChainWithExpectedTip("b1") + g.ForceTipReorg("b1", "b1a") + + // Restore the spend journal entry for block b1. + err := g.chain.db.Update(func(dbTx database.Tx) error { + spendBucket := dbTx.Metadata().Bucket(spendJournalBucketName) + return spendBucket.Put(b1Hash[:], serialized) + }) + if err != nil { + t.Fatalf("unexpected error putting spend journal entry: %v", err) + } + + // Validate that the tip is at b1a but the utxo cache flushed state is at + // b1. + g.ExpectTip("b1a") + g.ExpectUtxoSetState("b1") + + // Reset the cache and force a flush. + testUtxoCache = resetTestUtxoCache() + forceFlush(testUtxoCache) + + // Validate that the cache recovered and is now caught up to b1a. + g.ExpectUtxoSetState("b1a") +} + +// TestShutdownUtxoCache validates that a cache flush is forced when shutting +// down. +func TestShutdownUtxoCache(t *testing.T) { + // Create a test harness initialized with the genesis block as the tip. + params := chaincfg.RegNetParams() + g, teardownFunc := newChaingenHarness(t, params, "shutdownutxocachetest") + defer teardownFunc() + + // Replace the chain utxo cache with a test cache so that flushing can be + // disabled. + testUtxoCache := newTestUtxoCache(&UtxoCacheConfig{ + DB: g.chain.db, + MaxSize: 100 * 1024 * 1024, // 100 MiB + }) + g.chain.utxoCache = testUtxoCache + + // --------------------------------------------------------------------------- + // Generate and accept enough blocks to reach stake validation height. + // + // Disable flushing of the cache while advancing the chain. After reaching + // stake validation height, call shutdown and validate that it forces a flush + // and properly catches up to the tip. + // --------------------------------------------------------------------------- + + // Validate that the tip and utxo set state are currently at the genesis + // block. + g.ExpectTip("genesis") + g.ExpectUtxoSetState("genesis") + + // Disable flushing and advance the chain. + testUtxoCache.disableFlush = true + g.AdvanceToStakeValidationHeight() + + // Validate that the tip is at stake validation height but the utxo set state + // is still at the genesis block. + g.AssertTipHeight(uint32(params.StakeValidationHeight)) + g.ExpectUtxoSetState("genesis") + + // Enable flushing and shutdown the cache. + testUtxoCache.disableFlush = false + g.chain.ShutdownUtxoCache() + + // Validate that the utxo cache is now caught up to the tip. + g.ExpectUtxoSetState(g.TipName()) +} diff --git a/blockchain/utxoviewpoint.go b/blockchain/utxoviewpoint.go index 4c7c4783b9..3fba529b0c 100644 --- a/blockchain/utxoviewpoint.go +++ b/blockchain/utxoviewpoint.go @@ -25,7 +25,7 @@ import ( // The unspent outputs are needed by other transactions for things such as // script validation and double spend prevention. type UtxoViewpoint struct { - cache *UtxoCache + cache UtxoCacher entries map[wire.OutPoint]*UtxoEntry bestHash chainhash.Hash } @@ -757,7 +757,7 @@ func (view *UtxoViewpoint) clone() *UtxoViewpoint { } // NewUtxoViewpoint returns a new empty unspent transaction output view. -func NewUtxoViewpoint(cache *UtxoCache) *UtxoViewpoint { +func NewUtxoViewpoint(cache UtxoCacher) *UtxoViewpoint { return &UtxoViewpoint{ cache: cache, entries: make(map[wire.OutPoint]*UtxoEntry),