dcrd/internal/mining/mining_view_test.go
Ryan Staudt e9460eb358 multi: Rework utxoset/view to use outpoints.
This modifies the utxoset in the database and related UtxoViewpoint to
store and work with unspent transaction outputs on a per-output basis
instead of at a transaction level.

The primary motivation is to simplify the code, pave the way for a
utxo cache, and generally focus on optimizing runtime performance.

The tradeoff is that this approach does somewhat increase the size of
the serialized utxoset since it means that the transaction hash is
duplicated for each output as a part of the key and some additional
details are duplicated in each output.  The details duplicated in each
output include flags encoded into a single byte that specify whether the
containing transaction is a coinbase, whether the containing transaction
has an expiry, and the transaction type.  Additionally, the containing
block height and index are stored in each output.

However, in practice, the size difference isn't all that large, disk
space is relatively cheap, certainly cheaper than memory, and it is much
more important to provide more efficient runtime operation since that is
the ultimate purpose of the daemon.

While performing this conversion, it also simplifies the code to remove
the transaction version information from the utxoset as well as the
spend journal.  The logic for only serializing it under certain
circumstances is complicated, and it was only used for the gettxout RPC,
where it has already been removed.

The utxo set and spend journal in the database are automatically
migrated to the new format with this commit and it is possible to
interrupt and resume the migration process.

Finally, it also updates all references and tests that previously dealt
with transaction hashes to use outpoints instead.

An overview of the changes are as follows:

- Remove transaction version from both spent and unspent output entries
  - Update utxo serialization format to exclude the version
  - Update spend journal serialization format to exclude the version
- Convert UtxoEntry to represent a specific utxo instead of a
  transaction with all remaining utxos
  - Optimize for memory usage with an eye toward a utxo cache
    - Combine fields such as whether the containing transaction is a
      coinbase, whether the containing transaction has an expiry, and
      the transaction type into a single byte
    - Align entry fields to eliminate extra padding since ultimately
      there will be a lot of these in memory
    - Introduce a free list for serializing an outpoint to the database
      key format to significantly reduce pressure on the GC
  - Update entries to be keyed by a <hash><tree><output index> outpoint
    rather than just a tx hash
  - Update all related functions that previously dealt with transaction
    hashes to accept outpoints instead
  - Update all callers accordingly
  - Only add individually requested outputs from the mempool when
    constructing a mempool view
- Modify the spend journal to always store the encoded flags with every
  spent txout
  - Combine fields such as whether the containing transaction is a
    coinbase, whether the containing transaction has an expiry, and the
    transaction type into a single byte
    - Use 4 bits instead of 3 for the transaction type to be consistent
      with utxos. The extra bit was already unused so this doesn't take
      any additional space
  - Remove the fully spent flag
- Introduce ticketMinOuts in place of stakeExtra
  - Renamed stakeExtra as ticketMinOuts and updated all comments to make
    the purpose of the field clearer
  - Only store ticketMinOuts for ticket submission outputs
  - Add TicketMinimalOutputs function on UtxoEntry in place of
    ConvertUtxosToMinimalOutputs
- Always decompress data loaded from the database now that a utxo entry
  only consists of a specific output
- Introduce upgrade code to migrate the utxo set and spend journal to
  the new format
  - Update current database version to 9
  - Update current utxo set version to 3
  - Update current spend journal version to 3
  - Introduce the ability to run upgrades after the block index has been
    loaded
- Update all tests to expect the correct encodings, remove tests that no
  longer apply, and add new ones for the new expected behavior
  - Convert old tests for the legacy utxo format deserialization code to
    test the new function that is used during upgrade
- Introduce a few new functions on UtxoViewpoint
  - AddTxOut for adding an individual txout versus all of them
  - addTxOut to handle the common code between the new AddTxOut and
    existing AddTxOuts
  - RemoveEntry for removing an individual txout
- Remove the ErrDiscordantTxTree error
  - Since utxos are now retrieved using an outpoint, which includes the
    tree, it is no longer possible to hit this error path
2021-01-14 17:25:06 -06:00

528 lines
16 KiB
Go

// Copyright (c) 2020 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.
package mining
import (
"testing"
"github.com/decred/dcrd/blockchain/v4"
"github.com/decred/dcrd/chaincfg/chainhash"
"github.com/decred/dcrd/chaincfg/v3"
"github.com/decred/dcrd/dcrutil/v4"
"github.com/decred/dcrd/wire"
)
// Tests the behavior of the mining view when returned from a tx source
// containing a transaction chain depicted as:
//
// /--> d --> f
// |
// |--> b --|
// <coinbase> --> a |-->e
// |--> c --|
//
//
func TestMiningView(t *testing.T) {
harness, spendableOuts, err := newMiningHarness(chaincfg.MainNetParams())
if err != nil {
t.Fatalf("unable to create mining harness: %v", err)
}
applyTxFee := func(fee int64) func(*wire.MsgTx) {
return func(tx *wire.MsgTx) {
tx.TxOut[0].Value -= fee
}
}
// All transactions have the same number of outputs to keep the size uniform.
txA, _ := harness.CreateSignedTx([]spendableOutput{
spendableOuts[0],
}, 2, applyTxFee(1000))
txB, _ := harness.CreateSignedTx([]spendableOutput{
txOutToSpendableOut(txA, 0, wire.TxTreeRegular),
}, 2, applyTxFee(5000))
txC, _ := harness.CreateSignedTx([]spendableOutput{
txOutToSpendableOut(txA, 1, wire.TxTreeRegular),
}, 2, applyTxFee(5000))
txD, _ := harness.CreateSignedTx([]spendableOutput{
txOutToSpendableOut(txB, 0, wire.TxTreeRegular),
}, 2, applyTxFee(5000))
txE, _ := harness.CreateSignedTx([]spendableOutput{
txOutToSpendableOut(txB, 1, wire.TxTreeRegular),
txOutToSpendableOut(txC, 0, wire.TxTreeRegular),
}, 2, applyTxFee(5000))
txF, _ := harness.CreateSignedTx([]spendableOutput{
txOutToSpendableOut(txD, 0, wire.TxTreeRegular),
}, 2, applyTxFee(0))
// Add all to the tx source. Note that txB is out of order to test the
// behavior of an orphan transaction with ancestors brought into the tx source.
allTxns := []*dcrutil.Tx{txB, txA, txC, txD, txE}
for _, tx := range allTxns {
_, err = harness.AddTransactionToTxSource(tx)
if err != nil {
t.Fatalf("unable to add transaction to tx source: %v", err)
}
}
txALen := int64(txA.MsgTx().SerializeSize())
txBLen := int64(txB.MsgTx().SerializeSize())
txCLen := int64(txC.MsgTx().SerializeSize())
txATotalSigOps, err := harness.CountTotalSigOps(txA)
if err != nil {
t.Fatalf("failed to count sigops for txA %v", err)
}
txBTotalSigOps, err := harness.CountTotalSigOps(txB)
if err != nil {
t.Fatalf("failed to count sigops for txB %v", err)
}
txCTotalSigOps, err := harness.CountTotalSigOps(txC)
if err != nil {
t.Fatalf("failed to count sigops for txC %v", err)
}
tests := []struct {
name string
miningView TxMiningView
subject *dcrutil.Tx
expectedAncestorFees int64
expectedSizeBytes int64
expectedSigOps int
ancestors []*dcrutil.Tx
descendants map[chainhash.Hash]*dcrutil.Tx
orderedAncestors [][]*dcrutil.Tx
}{{
name: "Tx A",
subject: txA,
expectedAncestorFees: 0,
expectedSizeBytes: 0,
expectedSigOps: 0,
ancestors: nil,
descendants: map[chainhash.Hash]*dcrutil.Tx{
txB.MsgTx().TxHash(): txB,
txC.MsgTx().TxHash(): txC,
txD.MsgTx().TxHash(): txD,
txE.MsgTx().TxHash(): txE,
},
orderedAncestors: nil,
}, {
name: "Tx B",
subject: txB,
expectedAncestorFees: 1000,
expectedSizeBytes: txALen,
expectedSigOps: txATotalSigOps,
ancestors: []*dcrutil.Tx{txA},
descendants: map[chainhash.Hash]*dcrutil.Tx{
txD.MsgTx().TxHash(): txD,
txE.MsgTx().TxHash(): txE,
},
orderedAncestors: [][]*dcrutil.Tx{
{txA},
},
}, {
name: "Tx C",
subject: txC,
expectedAncestorFees: 1000,
expectedSizeBytes: txALen,
expectedSigOps: txATotalSigOps,
ancestors: []*dcrutil.Tx{txA},
descendants: map[chainhash.Hash]*dcrutil.Tx{
txE.MsgTx().TxHash(): txE,
},
orderedAncestors: [][]*dcrutil.Tx{
{txA},
},
}, {
name: "Tx D",
subject: txD,
expectedAncestorFees: 6000,
expectedSizeBytes: txALen + txBLen,
expectedSigOps: txATotalSigOps + txBTotalSigOps,
ancestors: []*dcrutil.Tx{txA, txB},
descendants: map[chainhash.Hash]*dcrutil.Tx{},
orderedAncestors: [][]*dcrutil.Tx{
{txA, txB},
},
}, {
name: "Tx E",
subject: txE,
expectedAncestorFees: 11000,
expectedSizeBytes: txALen + txBLen + txCLen,
expectedSigOps: txATotalSigOps + txBTotalSigOps +
txCTotalSigOps,
ancestors: []*dcrutil.Tx{txA, txB, txC},
descendants: map[chainhash.Hash]*dcrutil.Tx{},
orderedAncestors: [][]*dcrutil.Tx{
{txA, txB, txC},
{txA, txC, txB},
},
}}
for _, test := range tests {
txHash := test.subject.Hash()
txDesc := harness.txSource.pool[*txHash]
miningView := harness.txSource.MiningView()
// Make sure transaction is not rejected between view instances.
if miningView.isRejected(txHash) {
t.Fatalf("%v: expected test subject txn %v to not be rejected",
test.name, *txHash)
}
// Ensure stats are calculated before retrieving ancestors.
initialStat, hasStats := miningView.AncestorStats(txHash)
if !hasStats {
t.Fatalf("%v: expected mining view to have ancestor stats",
test.name)
}
if test.expectedAncestorFees != initialStat.Fees {
t.Fatalf("%v: expected subject txn to have bundle fees %v, got %v",
test.name, test.expectedAncestorFees, initialStat.Fees)
}
if test.expectedSizeBytes != initialStat.SizeBytes {
t.Fatalf("%v: expected subject txn to have SizeBytes=%v, got %v",
test.name, test.expectedSizeBytes, initialStat.SizeBytes)
}
if test.expectedSigOps != initialStat.TotalSigOps {
t.Fatalf("%v: expected subject txn to have SigOps=%v, got %v",
test.name, test.expectedSigOps, initialStat.TotalSigOps)
}
if len(test.ancestors) != initialStat.NumAncestors {
t.Fatalf("%v: expected subject txn to have NumAncestors=%v, got %v",
test.name, len(test.ancestors), initialStat.NumAncestors)
}
// Retrieving ancestors will cause the cached stats to be updated.
ancestors := miningView.ancestors(txHash)
stat, _ := miningView.AncestorStats(txHash)
if initialStat == stat {
t.Fatalf("%v: expected ancestor stats reference to have changed "+
"after retrieving list of ancestors", test.name)
}
// Get snapshot of transaction relationships as they exist in the tx source.
descendants := harness.txSource.miningView.descendants(txHash)
if len(test.ancestors) != len(ancestors) {
t.Fatalf("%v: expected subject txn to have %v ancestors, got %v",
test.name, len(test.ancestors), len(ancestors))
}
if len(ancestors) != stat.NumAncestors {
t.Fatalf("%v: expected subject txn to have NumAncestors=%v, got %v",
test.name, len(ancestors), stat.NumAncestors)
}
if len(descendants) != stat.NumDescendants {
t.Fatalf("%v: expected subject txn to have NumDescendants=%v, "+
"got %v", test.name, len(descendants), stat.NumDescendants)
}
for _, descendantHash := range descendants {
if _, exist := test.descendants[*descendantHash]; !exist {
t.Fatalf("%v: expected subject txn to have descendant=%v",
test.name, descendantHash)
}
}
// Ensure that transactions have a valid order, and that one of the test's
// orderedAncestors matches ancestors returned from the view exactly.
exactMatch := true
for _, ancestorGroups := range test.orderedAncestors {
exactMatch = true
for index, ancestor := range ancestorGroups {
if *ancestors[index].Tx.Hash() != *ancestor.Hash() {
exactMatch = false
break
}
}
if exactMatch {
break
}
}
if !exactMatch {
t.Fatalf("%v: subject txn ancestors returned out of order",
test.name)
}
if miningView.hasParents(txHash) && len(test.ancestors) == 0 {
t.Fatalf("%v: expected subject txn to not have 0 parents, got %v",
test.name, len(test.ancestors))
}
// Make sure all parents are valid outpoints from this tx.
txInputHashes := make(map[chainhash.Hash]struct{})
for _, outpoint := range test.subject.MsgTx().TxIn {
txInputHashes[outpoint.PreviousOutPoint.Hash] = struct{}{}
}
for _, parentDesc := range miningView.parents(txHash) {
if _, exist := txInputHashes[*parentDesc.Tx.Hash()]; !exist {
t.Fatalf("%v: test subject %v has invalid parent %v",
test.name, *txHash, *parentDesc.Tx.Hash())
}
}
// if the transaction has children, make sure it's no longer the child's
// ancestor after removal.
removalMiningView := harness.txSource.MiningView()
if children := removalMiningView.children(txHash); len(children) > 0 {
removalMiningView.RemoveTransaction(txHash, false)
child := children[0]
siblings := removalMiningView.parents(child.Tx.Hash())
// Make sure ancestor stats have not changed.
oldStat, hasStats := miningView.AncestorStats(child.Tx.Hash())
if !hasStats {
t.Fatalf("%v: Expected ancestor stats to be available",
test.name)
}
newStat, hasStats := removalMiningView.AncestorStats(
child.Tx.Hash())
if !hasStats {
t.Fatalf("%v: Expected ancestor stats to be available",
test.name)
}
if *oldStat != *newStat {
t.Fatalf("%v: expected test subject's child bundle stats to "+
"remain unchanged.", test.name)
}
for _, sibling := range siblings {
if *sibling.Tx.Hash() == *txHash {
t.Fatalf("%v: expected test subject to no longer be an "+
"ancestor of %v after being removed from the view.",
test.name, *sibling.Tx.Hash())
}
}
}
// reset the mining view after removing the tx and ensure that all descendants
// have the fee removed for this transaction when calling Remove with the
// option to do so.
removalMiningView = harness.txSource.MiningView()
removalMiningView.RemoveTransaction(txHash, true)
for _, descendant := range descendants {
oldStat, hasStats := miningView.AncestorStats(descendant)
if !hasStats {
t.Fatalf("%v: Expected ancestor stats to be available",
test.name)
}
newStat, hasStats := removalMiningView.AncestorStats(descendant)
if !hasStats {
t.Fatalf("%v: Expected ancestor stats to be available",
test.name)
}
expectedFee := oldStat.Fees - txDesc.Fee
expectedSigOps := oldStat.TotalSigOps - txDesc.TotalSigOps
expectedSizeBytes := oldStat.SizeBytes -
int64(txDesc.Tx.MsgTx().SerializeSize())
expectedNumAncestors := oldStat.NumAncestors - 1
if newStat.Fees != expectedFee {
t.Fatalf("%v: expected descendant to have adjusted "+
"Fees=%v, but got %v", test.name, expectedFee, newStat.Fees)
}
if newStat.TotalSigOps != expectedSigOps {
t.Fatalf("%v: expected descendant to have adjusted "+
"NumSigOps=%v, but got %v", test.name,
expectedSigOps, newStat.TotalSigOps)
}
if newStat.SizeBytes != expectedSizeBytes {
t.Fatalf("%v: expected descendant to have adjusted "+
"SizeBytes=%v, but got %v", test.name,
expectedSizeBytes, newStat.SizeBytes)
}
if newStat.NumAncestors != expectedNumAncestors {
t.Fatalf("%v: expected descendant to have "+
"NumAncestors=%v, but got %v", test.name,
expectedNumAncestors, newStat.NumAncestors)
}
}
miningView.reject(txHash)
if !miningView.isRejected(txHash) {
t.Fatalf("%v: expected test subject txn %v to be rejected",
test.name, *txHash)
}
// Rejecting a transaction rejects all descendant transactions and removes the
// tx from the graph.
if ancestors := miningView.ancestors(txHash); len(ancestors) > 0 {
t.Fatalf("%v: expected subject txn to have 0 ancestors, got %v",
test.name, len(ancestors))
}
for _, descendant := range descendants {
if !miningView.isRejected(descendant) {
t.Fatalf("%v: expected descendant txn %v to be rejected.",
test.name, *descendant)
}
}
}
// Get mining view snapshot prior to removing the tx from the tx source.
miningViewSnapshot := harness.txSource.MiningView()
// Remove txC and its descendants from the tx source.
harness.RemoveTransactionFromTxSource(txC, true)
// Add a new transaction to the tx source.
_, err = harness.AddTransactionToTxSource(txF)
if err != nil {
t.Fatalf("unable to add transaction to the tx source: %v", err)
}
miningView := harness.txSource.MiningView()
addedTxns := miningView.children(txD.Hash())
if len(addedTxns) != 1 {
t.Fatalf("AddTxnToTxSource: expected txD to have exactly 1 child, got %v",
len(addedTxns))
}
if addedTxns[0].Tx != txF {
t.Fatalf("AddTxnToTxSource: expected txF to exist in the mining view.")
}
// Ensure newly added transaction not added to the mining view snapshot.
if len(miningViewSnapshot.children(txD.Hash())) != 0 {
t.Fatalf("AddTxnToTxSource: expected txD to not have descendants in " +
"snapshot mining view.")
}
if len(miningView.children(txC.Hash())) != 0 {
t.Fatalf("RemoveTxnFromTxSource: expected txC to not have descendents " +
"after being removed from the tx source.")
}
// Ensure tx and children are still accessible in cloned view.
if len(miningViewSnapshot.children(txC.Hash())) != 1 {
t.Fatalf("RemoveTxnFromTxSource: expected txC to exist in snapshot view " +
"even though it was removed from the tx source.")
}
}
// TestAncestorTrackingLimits ensures that ancestor tracking is disabled for
// transactions that have too many ancestors.
func TestAncestorTrackingLimits(t *testing.T) {
harness, spendableOuts, err := newMiningHarness(chaincfg.MainNetParams())
if err != nil {
t.Fatalf("unable to create mining harness: %v", err)
}
// Create a chain of transactions.
var allTxns []*dcrutil.Tx
prevSpendableOut := spendableOuts[0]
for i := 0; i < ancestorTrackingLimit+2; i++ {
tx, _ := harness.CreateSignedTx([]spendableOutput{
prevSpendableOut,
}, 1)
allTxns = append(allTxns, tx)
prevSpendableOut = txOutToSpendableOut(tx, 0, wire.TxTreeRegular)
}
// Add all transactions to the tx source.
for _, tx := range allTxns {
_, err = harness.AddTransactionToTxSource(tx)
if err != nil {
t.Fatalf("unable to add transaction to the tx source: %v", err)
}
}
// Remove each transaction from the tx source to demonstrate that once a
// transaction has less ancestors than the limit that it begins having ancestor
// stats tracked and it continues to do so until it is removed from the tx
// source.
allTxnsQueue := allTxns
for len(allTxnsQueue) > 0 {
for index, tx := range allTxnsQueue {
_, hasStats := harness.txSource.miningView.AncestorStats(tx.Hash())
if index <= ancestorTrackingLimit && !hasStats {
t.Fatalf("expected transaction %v at index %v to have ancestor"+
" tracking enabled", tx.Hash(), index)
}
if index > ancestorTrackingLimit && hasStats {
t.Fatalf("expected transaction %v at index %v to not have "+
"ancestor tracking enabled", tx.Hash(), index)
}
}
tx := allTxnsQueue[0]
allTxnsQueue = allTxnsQueue[1:]
// Simulate the transaction being included in a block.
newBlockHeight := harness.chain.bestState.Height + 1
harness.AddFakeUTXO(tx, newBlockHeight, wire.NullBlockIndex,
harness.chain.isTreasuryAgendaActive)
harness.chain.bestState = blockchain.BestState{
Height: newBlockHeight,
}
harness.RemoveTransactionFromTxSource(tx, false)
harness.txSource.MaybeAcceptDependents(tx,
harness.chain.isTreasuryAgendaActive)
}
// Add all transactions back to the tx source in reverse order to demonstrate
// that a transaction that initially has ancestor stats tracked is later
// excluded from the ancestor tracked set when it has too many ancestors.
for index := len(allTxns) - 1; index >= 0; index-- {
tx := allTxns[index]
txHash := tx.Hash()
outpoint := wire.OutPoint{Hash: *txHash, Index: 0, Tree: wire.TxTreeRegular}
harness.chain.utxos.LookupEntry(outpoint).Spend()
_, err = harness.AddTransactionToTxSource(tx)
if err != nil {
t.Fatalf("unable to add transaction to the tx source: %v", err)
}
_, hasStats := harness.txSource.miningView.AncestorStats(txHash)
if !hasStats {
t.Fatalf("expected transaction %v at index %v to have ancestor"+
" tracking enabled when added back to the tx source", txHash, index)
}
}
for index, tx := range allTxns {
txHash := tx.Hash()
_, hasStats := harness.txSource.miningView.AncestorStats(txHash)
if index <= ancestorTrackingLimit && !hasStats {
t.Fatalf("expected transaction %v at index %v to have ancestor"+
" tracking enabled", txHash, index)
}
if index > ancestorTrackingLimit && hasStats {
t.Fatalf("expected transaction %v at index %v to not have "+
"ancestor tracking enabled", txHash, index)
}
}
}