From 573fa21454d032faef41b69c91d338fffbeda43c Mon Sep 17 00:00:00 2001 From: David Christensen Date: Fri, 23 Dec 2022 13:24:58 -0600 Subject: [PATCH v11] Teach pg_waldump to extract FPIs from the WAL stream Extracts full-page images from the WAL stream into a given target directory. These images are subject to the same filtering rules as normal display in pg_waldump, which means that you can isolate the full page writes to a target relation, among other things. Files are saved with the filename: ...._ with formatting to make things somewhat sortable; for instance: 00000000-010000C0.1663.1.6117.0_main 00000000-01000150.1664.0.6115.0_main 00000000-010001E0.1664.0.6114.0_main 00000000-01000270.1663.1.6116.0_main 00000000-01000300.1663.1.6113.0_main 00000000-01000390.1663.1.6112.0_main 00000000-01000420.1663.1.8903.0_main 00000000-010004B0.1663.1.8902.0_main 00000000-01000540.1663.1.6111.0_main 00000000-010005D0.1663.1.6110.0_main It's noteworthy that the raw block images do not have the current LSN stored with them in the WAL stream (as would be true for on-heap versions of the blocks), nor would the checksum be updated in them (though WAL itself has checksums, so there is some protection there). These images could be loaded/inspected via `pg_read_binary_file()` and used in the `pageinspect` suite of tools to perform detailed analysis on the pages in question, based on historical information, and may come in handy for forensics work. --- doc/src/sgml/ref/pg_waldump.sgml | 69 ++++++++++++++++ src/bin/pg_waldump/meson.build | 1 + src/bin/pg_waldump/pg_waldump.c | 87 +++++++++++++++++++- src/bin/pg_waldump/t/002_save_fullpage.pl | 99 +++++++++++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 src/bin/pg_waldump/t/002_save_fullpage.pl diff --git a/doc/src/sgml/ref/pg_waldump.sgml b/doc/src/sgml/ref/pg_waldump.sgml index d559f091e5..090205e122 100644 --- a/doc/src/sgml/ref/pg_waldump.sgml +++ b/doc/src/sgml/ref/pg_waldump.sgml @@ -240,6 +240,75 @@ PostgreSQL documentation + + + + + + Save full page images seen in the WAL stream to the + save_path directory, which will be created + if it does not exist. The images saved will be subject to the same + filtering and limiting criteria as display records, but in this + mode pg_waldump will not output any other + information. + + + The page images will be saved with the file + format: LSN.RELTABLESPACE.DATOID.RELNODE.BLKNOFORK + + The dot-separated components are (in order): + + + + + + Component + Description + + + + + + LSN + The LSN of the record with this block, formatted + as two 8-character hexadecimal numbers %08X-%08X + + + + RELTABLESPACE + tablespace OID for the block + + + + DATOID + database OID for the block + + + + RELNODE + relnode id for the block + + + + BLKNO + the block number of this block + + + + FORK + + The name of the fork the full page image came from. One + of _main, _fsm, + _vm, or _init. + + + + + + + + + diff --git a/src/bin/pg_waldump/meson.build b/src/bin/pg_waldump/meson.build index 3fa1b53e71..0428998350 100644 --- a/src/bin/pg_waldump/meson.build +++ b/src/bin/pg_waldump/meson.build @@ -31,6 +31,7 @@ tests += { 'tap': { 'tests': [ 't/001_basic.pl', + 't/002_save_fullpage.pl', ], }, } diff --git a/src/bin/pg_waldump/pg_waldump.c b/src/bin/pg_waldump/pg_waldump.c index 9993378ca5..81aeef3b97 100644 --- a/src/bin/pg_waldump/pg_waldump.c +++ b/src/bin/pg_waldump/pg_waldump.c @@ -23,9 +23,13 @@ #include "access/xlogrecord.h" #include "access/xlogstats.h" #include "common/fe_memutils.h" +#include "common/file_perm.h" +#include "common/file_utils.h" #include "common/logging.h" +#include "common/relpath.h" #include "getopt_long.h" #include "rmgrdesc.h" +#include "storage/bufpage.h" /* * NOTE: For any code change or issue fix here, it is highly recommended to @@ -70,6 +74,9 @@ typedef struct XLogDumpConfig bool filter_by_relation_block_enabled; ForkNumber filter_by_relation_forknum; bool filter_by_fpw; + + /* save options */ + char *save_fpi_path; } XLogDumpConfig; @@ -439,6 +446,61 @@ XLogRecordHasFPW(XLogReaderState *record) return false; } +/* + * Function to externally save all FPIs stored in the given WAL record + */ +static void +XLogRecordSaveFPIs(XLogReaderState *record, const char *savepath) +{ + int block_id; + + for (block_id = 0; block_id <= XLogRecMaxBlockId(record); block_id++) + { + PGAlignedBlock buf; + Page page; + char filename[MAXPGPATH]; + char forkname[FORKNAMECHARS + 2]; /* _ + \0 */ + FILE *OPF; + BlockNumber blk; + RelFileLocator rnode; + ForkNumber fork; + + if (!XLogRecHasBlockRef(record, block_id)) + continue; + + if (!XLogRecHasBlockImage(record, block_id)) + continue; + + page = (Page) buf.data; + + if (!RestoreBlockImage(record, block_id, page)) + continue; + + /* we have our extracted FPI, let's save it now */ + + XLogRecGetBlockTagExtended(record, block_id, + &rnode, &fork, &blk, NULL); + + if (fork >= 0 && fork <= MAX_FORKNUM) + sprintf(forkname, "_%s", forkNames[fork]); + else + pg_fatal("found invalid fork number: %u", fork); + + snprintf(filename, MAXPGPATH, "%s/%08X-%08X.%u.%u.%u.%u%s", savepath, + LSN_FORMAT_ARGS(record->ReadRecPtr), + rnode.spcOid, rnode.dbOid, rnode.relNumber, blk, forkname); + + OPF = fopen(filename, PG_BINARY_W); + if (!OPF) + pg_fatal("could not open file \"%s\"", filename); + + if (fwrite(page, BLCKSZ, 1, OPF) != 1) + pg_fatal("could not write file \"%s\"", filename); + + fclose(OPF); + } +} + /* * Print a record to stdout */ @@ -679,6 +741,8 @@ usage(void) " (default: 1 or the value used in STARTSEG)\n")); printf(_(" -V, --version output version information, then exit\n")); printf(_(" -w, --fullpage only show records with a full page write\n")); + printf(_(" -W, --save-fullpage=path save full page images to given path as\n" + " LSN.T.D.R.B_F\n")); printf(_(" -x, --xid=XID only show records with transaction ID XID\n")); printf(_(" -z, --stats[=record] show statistics instead of records\n" " (optionally, show per-record statistics)\n")); @@ -712,6 +776,7 @@ main(int argc, char **argv) {"limit", required_argument, NULL, 'n'}, {"path", required_argument, NULL, 'p'}, {"quiet", no_argument, NULL, 'q'}, + {"save-fullpage", required_argument, NULL, 'W'}, {"relation", required_argument, NULL, 'R'}, {"rmgr", required_argument, NULL, 'r'}, {"start", required_argument, NULL, 's'}, @@ -772,6 +837,7 @@ main(int argc, char **argv) config.filter_by_fpw = false; config.stats = false; config.stats_per_record = false; + config.save_fpi_path = NULL; stats.startptr = InvalidXLogRecPtr; stats.endptr = InvalidXLogRecPtr; @@ -782,7 +848,7 @@ main(int argc, char **argv) goto bad_argument; } - while ((option = getopt_long(argc, argv, "bB:e:fF:n:p:qr:R:s:t:wx:z", + while ((option = getopt_long(argc, argv, "bB:e:fF:n:p:qr:R:s:t:wW:x:z", long_options, &optindex)) != -1) { switch (option) @@ -918,6 +984,9 @@ main(int argc, char **argv) case 'w': config.filter_by_fpw = true; break; + case 'W': + config.save_fpi_path = pg_strdup(optarg); + break; case 'x': if (sscanf(optarg, "%u", &config.filter_by_xid) != 1) { @@ -972,6 +1041,17 @@ main(int argc, char **argv) } } + if (config.save_fpi_path != NULL) + { + /* Create the dir if it doesn't exist */ + if (pg_mkdir_p(config.save_fpi_path, pg_dir_create_mode) < 0) + { + pg_log_error("could not create output directory \"%s\": %m", + config.save_fpi_path); + goto bad_argument; + } + } + /* parse files as start/end boundaries, extract path if not specified */ if (optind < argc) { @@ -1150,6 +1230,11 @@ main(int argc, char **argv) XLogRecStoreStats(&stats, xlogreader_state); stats.endptr = xlogreader_state->EndRecPtr; } + else if (config.save_fpi_path != NULL) + { + if (XLogRecordHasFPW(xlogreader_state)) + XLogRecordSaveFPIs(xlogreader_state, config.save_fpi_path); + } else XLogDumpDisplayRecord(&config, xlogreader_state); } diff --git a/src/bin/pg_waldump/t/002_save_fullpage.pl b/src/bin/pg_waldump/t/002_save_fullpage.pl new file mode 100644 index 0000000000..35bb12e067 --- /dev/null +++ b/src/bin/pg_waldump/t/002_save_fullpage.pl @@ -0,0 +1,99 @@ + +# Copyright (c) 2022, PostgreSQL Global Development Group + +use strict; +use warnings; +use File::Basename; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::RecursiveCopy; +use PostgreSQL::Test::Utils; +use Test::More; + +my ($blocksize, $walfile_name); + +# routine to extract the LSN from the given block structure +sub get_block_info +{ + my $path = shift; + my $block; + + open my $fh, '<', $path or die "couldn't open file: $path\n"; + die "couldn't read full block\n" if $blocksize != read $fh, $block, $blocksize; + my ($lsn_hi, $lsn_lo) = unpack('VV', $block); + + $lsn_hi = sprintf('%08X', $lsn_hi); + $lsn_lo = sprintf('%08X', $lsn_lo); + + return ($lsn_hi, $lsn_lo); +} + +my $node = PostgreSQL::Test::Cluster->new('node_a'); +$node->init(allows_streaming => 1); +$node->start; + +# Sanity checks for command line options. +$node->command_fails( + [ 'pg_waldump', '--save-fullpage' ], + '--save-fullpage fails without path'); + +# generate data/wal to examine that will have FPIs in them +$node->safe_psql('postgres', "SELECT 'init' FROM pg_create_physical_replication_slot('regress_pg_waldump_slot', true, false); +CREATE TABLE test_table AS SELECT generate_series(1,100) a; +CHECKPOINT; -- required to force FPI for next writes +UPDATE test_table SET a = a + 1; +"); + +($walfile_name, $blocksize) = split '\|' => $node->safe_psql('postgres',"SELECT pg_walfile_name(pg_switch_wal()), current_setting('block_size')"); + +# get the relation node, etc for the new table +my $relation = $node->safe_psql('postgres', + q{SELECT format( + '%s/%s/%s', + CASE WHEN reltablespace = 0 THEN dattablespace ELSE reltablespace END, + pg_database.oid, + pg_relation_filenode(pg_class.oid)) + FROM pg_class, pg_database + WHERE relname = 'test_table' AND + datname = current_database()} +); + +my $walfile = $node->data_dir . '/pg_wal/' . $walfile_name; +my $tmp_folder = PostgreSQL::Test::Utils::tempdir; + +ok(-f $walfile, "Got a WAL file"); + +$node->command_ok(['pg_waldump', '--save-fullpage', "$tmp_folder/raw", '--relation', $relation, $walfile]); + +# This regexp will match filenames of the format XXXXXXXX-XXXXXXXX.dd.dd.dd.dd_fork +# with the components being: +# - WAL LSN in hex format, +# - Decimal database oid +# - Decimal tablespace oid or 0 for global +# - Decimal relfilenumber +# - Decimal block number +# - fork this block came from (one of vm, init, fsm, or main) +my $file_re = + qr/^([0-9A-F]{8})-([0-9A-F]{8})[.][0-9]+[.][0-9]+[.][0-9]+[.][0-9]+(?:_vm|_init|_fsm|_main)?$/; + +my $file_count = 0; + +# verify filename formats matches w/--save-fullpage +for my $fullpath (glob "$tmp_folder/raw/*") +{ + my $file = File::Basename::basename($fullpath); + + like($file, $file_re, "verify filename format for file $file"); + $file_count++; + + my ($hi_lsn_fn, $lo_lsn_fn) = ($file =~ $file_re); + my ($hi_lsn_bk, $lo_lsn_bk) = get_block_info($fullpath); + + # verify the lsn in the block comes before the file's lsn + ok( $hi_lsn_fn . $lo_lsn_fn gt $hi_lsn_bk . $lo_lsn_bk, + 'verify file-based LSN precedes block-based one'); +} + +# validate that we ended up with some FPIs saved +ok($file_count > 0, 'verify we processed some files'); + +done_testing(); -- 2.37.1 (Apple Git-137.1)