From 652a338524ad72a863c5b2be921085404e080279 Mon Sep 17 00:00:00 2001 From: Ryan Staudt Date: Thu, 18 Feb 2021 07:39:05 -0600 Subject: [PATCH 1/4] blockchain: Make InitUtxoCache a UtxoCache method. This changes the InitUtxoCache method on the BlockChain type to a Initialize method on the UtxoCache type instead. This simplifies providing alternative implementations for testing since the Initialize method deals with internal fields of the UtxoCache type. --- blockchain/chainio.go | 2 +- blockchain/utxocache.go | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) 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/utxocache.go b/blockchain/utxocache.go index 5a50b91cc1..0560379bcc 100644 --- a/blockchain/utxocache.go +++ b/blockchain/utxocache.go @@ -607,8 +607,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 +616,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 +639,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 +649,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 +680,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 +745,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 +753,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 +820,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 } From f937c637d9a30facd9bfd867174dea2d700f04a3 Mon Sep 17 00:00:00 2001 From: Ryan Staudt Date: Thu, 18 Feb 2021 09:57:00 -0600 Subject: [PATCH 2/4] blockchain: Add UtxoCacher interface. This adds a UtxoCacher interface so that alternative utxo cache implementations can be provided. In particular, this will be used to provide a mock implementation for testing in order to more easily simulate various scenarios. In addition to introducing the UtxoCacher interface, this updates BlockChain and UtxoViewpoint to use the interface rather than the concrete type. --- blockchain/chain.go | 4 +-- blockchain/utxocache.go | 50 +++++++++++++++++++++++++++++++++++-- blockchain/utxoviewpoint.go | 4 +-- 3 files changed, 52 insertions(+), 6 deletions(-) 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/utxocache.go b/blockchain/utxocache.go index 0560379bcc..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 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), From beccde2fb5228f4a876a1e8d86da8048a5290444 Mon Sep 17 00:00:00 2001 From: Ryan Staudt Date: Thu, 18 Feb 2021 10:24:10 -0600 Subject: [PATCH 3/4] blockchain: Add UtxoCache Initialize tests. This adds tests for the UtxoCache Initialize method. These tests include recovery scenarios that are particularly hard to simulate to ensure that those code paths are not broken in the future. In order to test recovery scenarios, this introduces a test utxo cache that allows for toggling flushing on and off. Additionally, this adds an ExpectUtxoSetState method to chaingenHarness that allows for easily validating the last flushed block for the utxo set. --- blockchain/common_test.go | 29 ++++++ blockchain/utxocache_test.go | 182 +++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) 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_test.go b/blockchain/utxocache_test.go index b7dca91818..b52233ea24 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,151 @@ 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") +} From 29e5b9b5a4fc20e907a73356d0bc4ec3eb4a0b02 Mon Sep 17 00:00:00 2001 From: Ryan Staudt Date: Thu, 18 Feb 2021 10:25:28 -0600 Subject: [PATCH 4/4] blockchain: Add TestShutdownUtxoCache tests. --- blockchain/utxocache_test.go | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/blockchain/utxocache_test.go b/blockchain/utxocache_test.go index b52233ea24..d3b860a3f4 100644 --- a/blockchain/utxocache_test.go +++ b/blockchain/utxocache_test.go @@ -1248,3 +1248,49 @@ func TestInitialize(t *testing.T) { // 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()) +}