/ test / functional / feature_index_prune.py
feature_index_prune.py
  1  #!/usr/bin/env python3
  2  # Copyright (c) 2020-present The Bitcoin Core developers
  3  # Distributed under the MIT software license, see the accompanying
  4  # file COPYING or http://www.opensource.org/licenses/mit-license.php.
  5  """Test indices in conjunction with prune."""
  6  import concurrent.futures
  7  import os
  8  from test_framework.authproxy import JSONRPCException
  9  from test_framework.test_framework import BitcoinTestFramework
 10  from test_framework.test_node import TestNode
 11  from test_framework.util import (
 12      assert_equal,
 13      assert_greater_than,
 14      assert_raises_rpc_error,
 15  )
 16  
 17  from typing import List, Any
 18  
 19  def send_batch_request(node: TestNode, method: str, params: List[Any]) -> List[Any]:
 20      """Send batch request and parse all results"""
 21      data = [{"method": method, "params": p} for p in params]
 22      response = node.batch(data)
 23      result = []
 24      for item in response:
 25          assert item["error"] is None, item["error"]
 26          result.append(item["result"])
 27  
 28      return result
 29  
 30  
 31  class FeatureIndexPruneTest(BitcoinTestFramework):
 32      def set_test_params(self):
 33          self.num_nodes = 4
 34          self.extra_args = [
 35              ["-fastprune", "-prune=1", "-blockfilterindex=1"],
 36              ["-fastprune", "-prune=1", "-coinstatsindex=1"],
 37              ["-fastprune", "-prune=1", "-blockfilterindex=1", "-coinstatsindex=1"],
 38              [],
 39          ]
 40  
 41      def setup_network(self):
 42          self.setup_nodes()  # No P2P connection, so that linear_sync works
 43  
 44      def linear_sync(self, node_from, *, height_from=None):
 45          # Linear sync over RPC, because P2P sync may not be linear
 46          to_height = node_from.getblockcount()
 47          if height_from is None:
 48              height_from = min([n.getblockcount() for n in self.nodes]) + 1
 49          with concurrent.futures.ThreadPoolExecutor(max_workers=self.num_nodes) as rpc_threads:
 50              for i in range(height_from, to_height + 1):
 51                  b = node_from.getblock(blockhash=node_from.getblockhash(i), verbosity=0)
 52                  list(rpc_threads.map(lambda n: n.submitblock(b), self.nodes))
 53  
 54      def generate(self, node, num_blocks, sync_fun=None):
 55          return super().generate(node, num_blocks, sync_fun=sync_fun or (lambda: self.linear_sync(node)))
 56  
 57      def sync_index(self, height):
 58          expected_filter = {
 59              'basic block filter index': {'synced': True, 'best_block_height': height},
 60          }
 61          self.wait_until(lambda: self.nodes[0].getindexinfo() == expected_filter)
 62  
 63          expected_stats = {
 64              'coinstatsindex': {'synced': True, 'best_block_height': height}
 65          }
 66          self.wait_until(lambda: self.nodes[1].getindexinfo() == expected_stats, timeout=150)
 67  
 68          expected = {**expected_filter, **expected_stats}
 69          self.wait_until(lambda: self.nodes[2].getindexinfo() == expected)
 70  
 71      def restart_without_indices(self):
 72          for i in range(3):
 73              self.restart_node(i, extra_args=["-fastprune", "-prune=1"])
 74  
 75      def check_for_block(self, node, hash):
 76          try:
 77              self.nodes[node].getblock(hash)
 78              return True
 79          except JSONRPCException:
 80              return False
 81  
 82      def run_test(self):
 83          filter_nodes = [self.nodes[0], self.nodes[2]]
 84          stats_nodes = [self.nodes[1], self.nodes[2]]
 85  
 86          self.log.info("check if we can access blockfilters and coinstats when pruning is enabled but no blocks are actually pruned")
 87          self.sync_index(height=200)
 88          tip = self.nodes[0].getbestblockhash()
 89          for node in filter_nodes:
 90              assert_greater_than(len(node.getblockfilter(tip)['filter']), 0)
 91          for node in stats_nodes:
 92              assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=tip)['muhash']
 93  
 94          self.generate(self.nodes[0], 500)
 95          self.sync_index(height=700)
 96  
 97          self.log.info("prune some blocks")
 98          for node in self.nodes[:2]:
 99              with node.assert_debug_log(['limited pruning to height 689']):
100                  pruneheight_new = node.pruneblockchain(400)
101                  # the prune heights used here and below are magic numbers that are determined by the
102                  # thresholds at which block files wrap, so they depend on disk serialization and default block file size.
103                  assert_equal(pruneheight_new, 248)
104  
105          self.log.info("check if we can access the tips blockfilter and coinstats when we have pruned some blocks")
106          tip = self.nodes[0].getbestblockhash()
107          for node in filter_nodes:
108              assert_greater_than(len(node.getblockfilter(tip)['filter']), 0)
109          for node in stats_nodes:
110              assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=tip)['muhash']
111  
112          self.log.info("check if we can access the blockfilter and coinstats of a pruned block")
113          height_hash = self.nodes[0].getblockhash(2)
114          for node in filter_nodes:
115              assert_greater_than(len(node.getblockfilter(height_hash)['filter']), 0)
116          for node in stats_nodes:
117              assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=height_hash)['muhash']
118  
119          # mine and sync index up to a height that will later be the pruneheight
120          self.generate(self.nodes[0], 51)
121          self.sync_index(height=751)
122  
123          self.restart_without_indices()
124  
125          self.log.info("make sure trying to access the indices throws errors")
126          for node in filter_nodes:
127              msg = "Index is not enabled for filtertype basic"
128              assert_raises_rpc_error(-1, msg, node.getblockfilter, height_hash)
129          for node in stats_nodes:
130              msg = "Querying specific block heights requires coinstatsindex"
131              assert_raises_rpc_error(-8, msg, node.gettxoutsetinfo, "muhash", height_hash)
132  
133          self.generate(self.nodes[0], 749)
134  
135          self.log.info("prune exactly up to the indices best blocks while the indices are disabled")
136          for i in range(3):
137              pruneheight_2 = self.nodes[i].pruneblockchain(1000)
138              assert_equal(pruneheight_2, 750)
139              # Restart the nodes again with the indices activated
140              self.restart_node(i, extra_args=self.extra_args[i])
141  
142          self.log.info("make sure that we can continue with the partially synced indices after having pruned up to the index height")
143          self.sync_index(height=1500)
144  
145          self.log.info("prune further than the indices best blocks while the indices are disabled")
146          self.restart_without_indices()
147          self.generate(self.nodes[0], 1000)
148  
149          for i in range(3):
150              pruneheight_3 = self.nodes[i].pruneblockchain(2000)
151              assert_greater_than(pruneheight_3, pruneheight_2)
152              self.stop_node(i)
153  
154          self.log.info("make sure we get an init error when starting the nodes again with the indices")
155          filter_msg = "Error: basic block filter index best block of the index goes beyond pruned data (including undo data). Please disable the index or reindex (which will download the whole blockchain again)"
156          stats_msg = "Error: coinstatsindex best block of the index goes beyond pruned data (including undo data). Please disable the index or reindex (which will download the whole blockchain again)"
157          end_msg = f"{os.linesep}Error: A fatal internal error occurred, see debug.log for details: Failed to start indexes, shutting down…"
158          for i, msg in enumerate([filter_msg, stats_msg, filter_msg]):
159              self.nodes[i].assert_start_raises_init_error(extra_args=self.extra_args[i], expected_msg=msg+end_msg)
160  
161          self.log.info("fetching the missing blocks with getblockfrompeer doesn't work for block filter index and coinstatsindex")
162          # Only checking the first two nodes since this test takes a long time
163          # and the third node is kind of redundant in this context
164          for i, msg in enumerate([filter_msg, stats_msg]):
165              self.restart_node(i, extra_args=["-prune=1", "-fastprune"])
166              node = self.nodes[i]
167              prune_height = node.getblockchaininfo()["pruneheight"]
168              self.connect_nodes(i, 3)
169              peers = node.getpeerinfo()
170              assert_equal(len(peers), 1)
171              peer_id = peers[0]["id"]
172  
173              # 1500 is the height to where the indices were able to sync previously
174              hashes = send_batch_request(node, "getblockhash", [[a] for a in range(1500, prune_height)])
175              send_batch_request(node, "getblockfrompeer", [[bh, peer_id] for bh in hashes])
176              # Ensure all necessary blocks have been fetched before proceeding
177              for bh in hashes:
178                  self.wait_until(lambda: self.check_for_block(i, bh), timeout=10)
179  
180              # Upon restart we expect the same errors as previously although all
181              # necessary blocks have been fetched. Both indices need the undo
182              # data of the blocks to be available as well and getblockfrompeer
183              # can not provide that.
184              self.stop_node(i)
185              node.assert_start_raises_init_error(extra_args=self.extra_args[i], expected_msg=msg+end_msg)
186  
187          self.log.info("make sure the nodes start again with the indices and an additional -reindex arg")
188          for i in range(3):
189              restart_args = self.extra_args[i] + ["-reindex"]
190              self.restart_node(i, extra_args=restart_args)
191  
192          self.linear_sync(self.nodes[3])
193          self.sync_index(height=2500)
194  
195          for node in self.nodes[:2]:
196              with node.assert_debug_log(['limited pruning to height 2489']):
197                  pruneheight_new = node.pruneblockchain(2500)
198                  assert_equal(pruneheight_new, 2005)
199  
200          self.log.info("ensure that prune locks don't prevent indices from failing in a reorg scenario")
201          with self.nodes[0].assert_debug_log(['basic block filter index prune lock moved back to 2480']):
202              self.nodes[3].invalidateblock(self.nodes[0].getblockhash(2480))
203              self.generate(self.nodes[3], 30, sync_fun=lambda: self.linear_sync(self.nodes[3], height_from=2480))
204  
205  
206  if __name__ == '__main__':
207      FeatureIndexPruneTest(__file__).main()