interface_usdt_utxocache.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2022-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  
   6  """ Tests the utxocache:* tracepoint API interface.
   7      See https://github.com/bitcoin/bitcoin/blob/master/doc/tracing.md#context-utxocache
   8  """
   9  
  10  import ctypes
  11  # Test will be skipped if we don't have bcc installed
  12  try:
  13      from bcc import BPF, USDT # type: ignore[import]
  14  except ImportError:
  15      pass
  16  from test_framework.messages import COIN
  17  from test_framework.test_framework import BitcoinTestFramework
  18  from test_framework.util import (
  19      assert_equal,
  20      bpf_cflags,
  21  )
  22  from test_framework.wallet import MiniWallet
  23  
  24  utxocache_changes_program = """
  25  #include <uapi/linux/ptrace.h>
  26  
  27  typedef signed long long i64;
  28  
  29  struct utxocache_change
  30  {
  31      char        txid[32];
  32      u32         index;
  33      u32         height;
  34      i64         value;
  35      bool        is_coinbase;
  36  };
  37  
  38  BPF_PERF_OUTPUT(utxocache_add);
  39  int trace_utxocache_add(struct pt_regs *ctx) {
  40      struct utxocache_change add = {};
  41      void *ptxid = NULL;
  42      bpf_usdt_readarg(1, ctx, &ptxid);
  43      bpf_probe_read_user(&add.txid, sizeof(add.txid), ptxid);
  44      bpf_usdt_readarg(2, ctx, &add.index);
  45      bpf_usdt_readarg(3, ctx, &add.height);
  46      bpf_usdt_readarg(4, ctx, &add.value);
  47      bpf_usdt_readarg(5, ctx, &add.is_coinbase);
  48      utxocache_add.perf_submit(ctx, &add, sizeof(add));
  49      return 0;
  50  }
  51  
  52  BPF_PERF_OUTPUT(utxocache_spent);
  53  int trace_utxocache_spent(struct pt_regs *ctx) {
  54      struct utxocache_change spent = {};
  55      void *ptxid = NULL;
  56      bpf_usdt_readarg(1, ctx, &ptxid);
  57      bpf_probe_read_user(&spent.txid, sizeof(spent.txid), ptxid);
  58      bpf_usdt_readarg(2, ctx, &spent.index);
  59      bpf_usdt_readarg(3, ctx, &spent.height);
  60      bpf_usdt_readarg(4, ctx, &spent.value);
  61      bpf_usdt_readarg(5, ctx, &spent.is_coinbase);
  62      utxocache_spent.perf_submit(ctx, &spent, sizeof(spent));
  63      return 0;
  64  }
  65  
  66  BPF_PERF_OUTPUT(utxocache_uncache);
  67  int trace_utxocache_uncache(struct pt_regs *ctx) {
  68      struct utxocache_change uncache = {};
  69      void *ptxid = NULL;
  70      bpf_usdt_readarg(1, ctx, &ptxid);
  71      bpf_probe_read_user(&uncache.txid, sizeof(uncache.txid), ptxid);
  72      bpf_usdt_readarg(2, ctx, &uncache.index);
  73      bpf_usdt_readarg(3, ctx, &uncache.height);
  74      bpf_usdt_readarg(4, ctx, &uncache.value);
  75      bpf_usdt_readarg(5, ctx, &uncache.is_coinbase);
  76      utxocache_uncache.perf_submit(ctx, &uncache, sizeof(uncache));
  77      return 0;
  78  }
  79  """
  80  
  81  utxocache_flushes_program = """
  82  #include <uapi/linux/ptrace.h>
  83  
  84  typedef signed long long i64;
  85  
  86  struct utxocache_flush
  87  {
  88      i64         duration;
  89      u32         mode;
  90      u64         size;
  91      u64         memory;
  92      bool        for_prune;
  93  };
  94  
  95  BPF_PERF_OUTPUT(utxocache_flush);
  96  int trace_utxocache_flush(struct pt_regs *ctx) {
  97      struct utxocache_flush flush = {};
  98      bpf_usdt_readarg(1, ctx, &flush.duration);
  99      bpf_usdt_readarg(2, ctx, &flush.mode);
 100      bpf_usdt_readarg(3, ctx, &flush.size);
 101      bpf_usdt_readarg(4, ctx, &flush.memory);
 102      bpf_usdt_readarg(5, ctx, &flush.for_prune);
 103      utxocache_flush.perf_submit(ctx, &flush, sizeof(flush));
 104      return 0;
 105  }
 106  """
 107  
 108  FLUSHMODE_NAME = {
 109      0: "NONE",
 110      1: "IF_NEEDED",
 111      2: "PERIODIC",
 112      3: "FORCE_FLUSH",
 113      4: "FORCE_SYNC",
 114  }
 115  
 116  
 117  class UTXOCacheChange(ctypes.Structure):
 118      _fields_ = [
 119          ("txid", ctypes.c_ubyte * 32),
 120          ("index", ctypes.c_uint32),
 121          ("height", ctypes.c_uint32),
 122          ("value", ctypes.c_uint64),
 123          ("is_coinbase", ctypes.c_bool),
 124      ]
 125  
 126      def __repr__(self):
 127          return f"UTXOCacheChange(outpoint={bytes(self.txid[::-1]).hex()}:{self.index}, height={self.height}, value={self.value}sat, is_coinbase={self.is_coinbase})"
 128  
 129  
 130  class UTXOCacheFlush(ctypes.Structure):
 131      _fields_ = [
 132          ("duration", ctypes.c_int64),
 133          ("mode", ctypes.c_uint32),
 134          ("size", ctypes.c_uint64),
 135          ("memory", ctypes.c_uint64),
 136          ("for_prune", ctypes.c_bool),
 137      ]
 138  
 139      def __repr__(self):
 140          return f"UTXOCacheFlush(duration={self.duration}, mode={FLUSHMODE_NAME[self.mode]}, size={self.size}, memory={self.memory}, for_prune={self.for_prune})"
 141  
 142  
 143  class UTXOCacheTracepointTest(BitcoinTestFramework):
 144      def set_test_params(self):
 145          self.setup_clean_chain = False
 146          self.num_nodes = 1
 147          self.extra_args = [["-txindex"]]
 148  
 149      def skip_test_if_missing_module(self):
 150          self.skip_if_platform_not_linux()
 151          self.skip_if_no_bitcoind_tracepoints()
 152          self.skip_if_no_python_bcc()
 153          self.skip_if_no_bpf_permissions()
 154          self.skip_if_running_under_valgrind()
 155  
 156      def run_test(self):
 157          self.wallet = MiniWallet(self.nodes[0])
 158  
 159          self.test_uncache()
 160          self.test_add_spent()
 161          self.test_flush()
 162  
 163      def test_uncache(self):
 164          """ Tests the utxocache:uncache tracepoint API.
 165          https://github.com/bitcoin/bitcoin/blob/master/doc/tracing.md#tracepoint-utxocacheuncache
 166          """
 167          # To trigger an UTXO uncache from the cache, we create an invalid transaction
 168          # spending a not-cached, but existing UTXO. During transaction validation, this
 169          # the UTXO is added to the utxo cache, but as the transaction is invalid, it's
 170          # uncached again.
 171          self.log.info("testing the utxocache:uncache tracepoint API")
 172  
 173          # Retrieve the txid for the UTXO created in the first block. This UTXO is not
 174          # in our UTXO cache.
 175          EARLY_BLOCK_HEIGHT = 1
 176          block_1_hash = self.nodes[0].getblockhash(EARLY_BLOCK_HEIGHT)
 177          block_1 = self.nodes[0].getblock(block_1_hash)
 178          block_1_coinbase_txid = block_1["tx"][0]
 179  
 180          # Create a transaction and invalidate it by changing the txid of the previous
 181          # output to the coinbase txid of the block at height 1.
 182          invalid_tx = self.wallet.create_self_transfer()["tx"]
 183          invalid_tx.vin[0].prevout.hash = int(block_1_coinbase_txid, 16)
 184  
 185          self.log.info("hooking into the utxocache:uncache tracepoint")
 186          ctx = USDT(pid=self.nodes[0].process.pid)
 187          ctx.enable_probe(probe="utxocache:uncache",
 188                           fn_name="trace_utxocache_uncache")
 189          bpf = BPF(text=utxocache_changes_program, usdt_contexts=[ctx], debug=0, cflags=bpf_cflags())
 190  
 191          # The handle_* function is a ctypes callback function called from C. When
 192          # we assert in the handle_* function, the AssertError doesn't propagate
 193          # back to Python. The exception is ignored. We manually count and assert
 194          # that the handle_* functions succeeded.
 195          EXPECTED_HANDLE_UNCACHE_SUCCESS = 1
 196          handle_uncache_succeeds = 0
 197  
 198          def handle_utxocache_uncache(_, data, __):
 199              nonlocal handle_uncache_succeeds
 200              event = ctypes.cast(data, ctypes.POINTER(UTXOCacheChange)).contents
 201              self.log.info(f"handle_utxocache_uncache(): {event}")
 202              try:
 203                  assert_equal(block_1_coinbase_txid, bytes(event.txid[::-1]).hex())
 204                  assert_equal(0, event.index)  # prevout index
 205                  assert_equal(EARLY_BLOCK_HEIGHT, event.height)
 206                  assert_equal(50 * COIN, event.value)
 207                  assert_equal(True, event.is_coinbase)
 208              except AssertionError:
 209                  self.log.exception("Assertion failed")
 210              else:
 211                  handle_uncache_succeeds += 1
 212  
 213          bpf["utxocache_uncache"].open_perf_buffer(handle_utxocache_uncache)
 214  
 215          self.log.info(
 216              "testmempoolaccept the invalid transaction to trigger an UTXO-cache uncache")
 217          result = self.nodes[0].testmempoolaccept(
 218              [invalid_tx.serialize().hex()])[0]
 219          assert_equal(result["allowed"], False)
 220  
 221          bpf.perf_buffer_poll(timeout=100)
 222          bpf.cleanup()
 223  
 224          self.log.info(
 225              f"check that we successfully traced {EXPECTED_HANDLE_UNCACHE_SUCCESS} uncaches")
 226          assert_equal(EXPECTED_HANDLE_UNCACHE_SUCCESS, handle_uncache_succeeds)
 227  
 228      def test_add_spent(self):
 229          """ Tests the utxocache:add utxocache:spent tracepoint API
 230              See https://github.com/bitcoin/bitcoin/blob/master/doc/tracing.md#tracepoint-utxocacheadd
 231              and https://github.com/bitcoin/bitcoin/blob/master/doc/tracing.md#tracepoint-utxocachespent
 232          """
 233  
 234          self.log.info(
 235              "test the utxocache:add and utxocache:spent tracepoint API")
 236  
 237          self.log.info("create an unconfirmed transaction")
 238          self.wallet.send_self_transfer(from_node=self.nodes[0])
 239  
 240          # We mine a block to trace changes (add/spent) to the active in-memory cache
 241          # of the UTXO set (see CoinsTip() of CCoinsViewCache). However, in some cases
 242          # temporary clones of the active cache are made. For example, during mining with
 243          # the generate RPC call, the block is first tested in TestBlockValidity(). There,
 244          # a clone of the active cache is modified during a test ConnectBlock() call.
 245          # These are implementation details we don't want to test here. Thus, after
 246          # mining, we invalidate the block, start the tracing, and then trace the cache
 247          # changes to the active utxo cache.
 248          self.log.info("mine and invalidate a block that is later reconsidered")
 249          block_hash = self.generate(self.wallet, 1)[0]
 250          self.nodes[0].invalidateblock(block_hash)
 251  
 252          self.log.info(
 253              "hook into the utxocache:add and utxocache:spent tracepoints")
 254          ctx = USDT(pid=self.nodes[0].process.pid)
 255          ctx.enable_probe(probe="utxocache:add", fn_name="trace_utxocache_add")
 256          ctx.enable_probe(probe="utxocache:spent",
 257                           fn_name="trace_utxocache_spent")
 258          bpf = BPF(text=utxocache_changes_program, usdt_contexts=[ctx], debug=0, cflags=bpf_cflags())
 259  
 260          # The handle_* function is a ctypes callback function called from C. When
 261          # we assert in the handle_* function, the AssertError doesn't propagate
 262          # back to Python. The exception is ignored. We manually count and assert
 263          # that the handle_* functions succeeded.
 264          EXPECTED_HANDLE_ADD_SUCCESS = 2
 265          EXPECTED_HANDLE_SPENT_SUCCESS = 1
 266  
 267          expected_utxocache_adds = []
 268          expected_utxocache_spents = []
 269  
 270          actual_utxocache_adds = []
 271          actual_utxocache_spents = []
 272  
 273          def handle_utxocache_add(_, data, __):
 274              event = ctypes.cast(data, ctypes.POINTER(UTXOCacheChange)).contents
 275              self.log.info(f"handle_utxocache_add(): {event}")
 276              actual_utxocache_adds.append(event)
 277  
 278          def handle_utxocache_spent(_, data, __):
 279              event = ctypes.cast(data, ctypes.POINTER(UTXOCacheChange)).contents
 280              self.log.info(f"handle_utxocache_spent(): {event}")
 281              actual_utxocache_spents.append(event)
 282  
 283          bpf["utxocache_add"].open_perf_buffer(handle_utxocache_add)
 284          bpf["utxocache_spent"].open_perf_buffer(handle_utxocache_spent)
 285  
 286          # We trigger a block re-connection. This causes changes (add/spent)
 287          # to the UTXO-cache which in turn triggers the tracepoints.
 288          self.log.info("reconsider the previously invalidated block")
 289          self.nodes[0].reconsiderblock(block_hash)
 290  
 291          block = self.nodes[0].getblock(block_hash, 2)
 292          for (block_index, tx) in enumerate(block["tx"]):
 293              for vin in tx["vin"]:
 294                  if "coinbase" not in vin:
 295                      prevout_tx = self.nodes[0].getrawtransaction(
 296                          vin["txid"], True)
 297                      prevout_tx_block = self.nodes[0].getblockheader(
 298                          prevout_tx["blockhash"])
 299                      spends_coinbase = "coinbase" in prevout_tx["vin"][0]
 300                      expected_utxocache_spents.append({
 301                          "txid": vin["txid"],
 302                          "index": vin["vout"],
 303                          "height": prevout_tx_block["height"],
 304                          "value": int(prevout_tx["vout"][vin["vout"]]["value"] * COIN),
 305                          "is_coinbase": spends_coinbase,
 306                      })
 307              for (i, vout) in enumerate(tx["vout"]):
 308                  if vout["scriptPubKey"]["type"] != "nulldata":
 309                      expected_utxocache_adds.append({
 310                          "txid": tx["txid"],
 311                          "index": i,
 312                          "height": block["height"],
 313                          "value": int(vout["value"] * COIN),
 314                          "is_coinbase": block_index == 0,
 315                      })
 316  
 317          bpf.perf_buffer_poll(timeout=200)
 318  
 319          assert_equal(EXPECTED_HANDLE_ADD_SUCCESS, len(expected_utxocache_adds), len(actual_utxocache_adds))
 320          assert_equal(EXPECTED_HANDLE_SPENT_SUCCESS, len(expected_utxocache_spents), len(actual_utxocache_spents))
 321  
 322          self.log.info(
 323              f"check that we successfully traced {EXPECTED_HANDLE_ADD_SUCCESS} adds and {EXPECTED_HANDLE_SPENT_SUCCESS} spent")
 324  
 325          # Check that all expected tracepoints are received, but not the order they were received in.
 326          # Tracepoint ordering is not strictly guaranteed, so this comparison avoids intermittent failures in the test.
 327          def cache_event_to_key(event):
 328              return (
 329                  bytes(event.txid[::-1]).hex(),
 330                  event.index,
 331                  event.height,
 332                  event.value,
 333                  event.is_coinbase
 334              )
 335  
 336          expected_add_keys = {(e["txid"], e["index"], e["height"], e["value"], e["is_coinbase"])
 337                               for e in expected_utxocache_adds}
 338          expected_spent_keys = {(e["txid"], e["index"], e["height"], e["value"], e["is_coinbase"])
 339                                for e in expected_utxocache_spents}
 340  
 341          actual_add_keys = {cache_event_to_key(e) for e in actual_utxocache_adds}
 342          actual_spent_keys = {cache_event_to_key(e) for e in actual_utxocache_spents}
 343  
 344          assert_equal(expected_add_keys, actual_add_keys)
 345          assert_equal(expected_spent_keys, actual_spent_keys)
 346  
 347          bpf.cleanup()
 348  
 349      def test_flush(self):
 350          """ Tests the utxocache:flush tracepoint API.
 351              See https://github.com/bitcoin/bitcoin/blob/master/doc/tracing.md#tracepoint-utxocacheflush"""
 352  
 353          self.log.info("test the utxocache:flush tracepoint API")
 354          self.log.info("hook into the utxocache:flush tracepoint")
 355          ctx = USDT(pid=self.nodes[0].process.pid)
 356          ctx.enable_probe(probe="utxocache:flush",
 357                           fn_name="trace_utxocache_flush")
 358          bpf = BPF(text=utxocache_flushes_program, usdt_contexts=[ctx], debug=0, cflags=bpf_cflags())
 359  
 360          # The handle_* function is a ctypes callback function called from C. When
 361          # we assert in the handle_* function, the AssertError doesn't propagate
 362          # back to Python. The exception is ignored. We manually count and assert
 363          # that the handle_* functions succeeded.
 364          EXPECTED_HANDLE_FLUSH_SUCCESS = 3
 365          handle_flush_succeeds = 0
 366          expected_flushes = list()
 367  
 368          def handle_utxocache_flush(_, data, __):
 369              nonlocal handle_flush_succeeds
 370              event = ctypes.cast(data, ctypes.POINTER(UTXOCacheFlush)).contents
 371              self.log.info(f"handle_utxocache_flush(): {event}")
 372              expected_flushes.remove({
 373                  "mode": FLUSHMODE_NAME[event.mode],
 374                  "for_prune": event.for_prune,
 375                  "size": event.size
 376              })
 377              # sanity checks only
 378              try:
 379                  assert event.memory > 0
 380                  assert event.duration > 0
 381              except AssertionError:
 382                  self.log.exception("Assertion error")
 383              else:
 384                  handle_flush_succeeds += 1
 385  
 386          bpf["utxocache_flush"].open_perf_buffer(handle_utxocache_flush)
 387  
 388          self.log.info("stop the node to flush the UTXO cache")
 389          UTXOS_IN_CACHE = 3 # might need to be changed if the earlier tests are modified
 390          # A node shutdown causes two flushes. One that flushes UTXOS_IN_CACHE
 391          # UTXOs and one that flushes 0 UTXOs. Normally the 0-UTXO-flush is the
 392          # second flush, however it can happen that the order changes.
 393          expected_flushes.append({"mode": "FORCE_FLUSH", "for_prune": False, "size": UTXOS_IN_CACHE})
 394          expected_flushes.append({"mode": "FORCE_FLUSH", "for_prune": False, "size": 0})
 395          self.stop_node(0)
 396  
 397          bpf.perf_buffer_poll(timeout=200)
 398          bpf.cleanup()
 399  
 400          self.log.info("check that we don't expect additional flushes")
 401          assert_equal(0, len(expected_flushes))
 402  
 403          self.log.info("restart the node with -prune")
 404          self.start_node(0, ["-fastprune=1", "-prune=1"])
 405  
 406          BLOCKS_TO_MINE = 350
 407          self.log.info(f"mine {BLOCKS_TO_MINE} blocks to be able to prune")
 408          self.generate(self.wallet, BLOCKS_TO_MINE)
 409  
 410          self.log.info("test the utxocache:flush tracepoint API with pruning")
 411          self.log.info("hook into the utxocache:flush tracepoint")
 412          ctx = USDT(pid=self.nodes[0].process.pid)
 413          ctx.enable_probe(probe="utxocache:flush",
 414                           fn_name="trace_utxocache_flush")
 415          bpf = BPF(text=utxocache_flushes_program, usdt_contexts=[ctx], debug=0, cflags=bpf_cflags())
 416          bpf["utxocache_flush"].open_perf_buffer(handle_utxocache_flush)
 417  
 418          self.log.info("prune blockchain to trigger a flush for pruning")
 419          expected_flushes.append({"mode": "NONE", "for_prune": True, "size": BLOCKS_TO_MINE})
 420          self.nodes[0].pruneblockchain(315)
 421  
 422          bpf.perf_buffer_poll(timeout=500)
 423          bpf.cleanup()
 424  
 425          self.log.info(
 426              "check that we don't expect additional flushes and that the handle_* function succeeded")
 427          assert_equal(0, len(expected_flushes))
 428          assert_equal(EXPECTED_HANDLE_FLUSH_SUCCESS, handle_flush_succeeds)
 429  
 430  
 431  if __name__ == '__main__':
 432      UTXOCacheTracepointTest(__file__).main()
 433