From 54f35f93c9f42d81d684e4487d296759d2b16253 Mon Sep 17 00:00:00 2001 From: Jelte Fennema-Nio Date: Mon, 27 Jul 2026 00:11:59 +0200 Subject: [PATCH v1 10/12] POC: recovery: port TAP test 029_stats_restart to pytest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Perl helpers read one column per psql launch and collect the values in a hash, which resulted in code like this: $results{records} = $node->safe_psql($connect_db, "SELECT wal_records FROM pg_stat_wal"); $results{bytes} = $node->safe_psql($connect_db, "SELECT wal_bytes FROM pg_stat_wal"); $results{reset} = $node->safe_psql($connect_db, "SELECT stats_reset FROM pg_stat_wal"); In Python the equivalent is this: return WalStats( *node.sql("SELECT wal_records, wal_bytes, stats_reset FROM pg_stat_wal") ) Besides being one query instead of three, the columns then come from a single read of the view rather than three taken at different moments. Test runtime changes in CI: platform perl pytest diff ----------- ---------------- ---------------- ---------------- windows 7.8s (±0.1) 3.8s (±0.1) -4.0s (-52%) mingw 6.2s (±0.1) 3.5s (±0.1) -2.7s (-43%) macos 6.0s (±0.3) 4.5s (±0.3) -1.5s (-25%) linux-64 4.4s (±0.0) 3.2s (±0.0) -1.2s (-28%) linux-32 2.7s (±0.0) 2.4s (±0.0) -0.3s (-12%) Timings are means of 5 runs of each form, interleaved on one CI runner with nothing else running on it; ± is the standard deviation across the 5 runs. LOC (no comments or blanks, with tokei): 264 -> 185 (-30%). --- src/test/recovery/meson.build | 2 +- .../recovery/pyt/test_029_stats_restart.py | 305 ++++++++++++++ src/test/recovery/t/029_stats_restart.pl | 376 ------------------ 3 files changed, 306 insertions(+), 377 deletions(-) create mode 100644 src/test/recovery/pyt/test_029_stats_restart.py delete mode 100644 src/test/recovery/t/029_stats_restart.pl diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 6e4c5a6484b..955f5e6a316 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -7,6 +7,7 @@ tests += { 'pytest': { 'test_kwargs': {'priority': 40}, # recovery tests are slow, start early 'tests': [ + 'pyt/test_029_stats_restart.py', 'pyt/test_049_wait_for_lsn.py', ], }, @@ -43,7 +44,6 @@ tests += { 't/026_overwrite_contrecord.pl', 't/027_stream_regress.pl', 't/028_pitr_timelines.pl', - 't/029_stats_restart.pl', 't/030_stats_cleanup_replica.pl', 't/031_recovery_conflict.pl', 't/032_relfilenode_reuse.pl', diff --git a/src/test/recovery/pyt/test_029_stats_restart.py b/src/test/recovery/pyt/test_029_stats_restart.py new file mode 100644 index 00000000000..a3d0204da7c --- /dev/null +++ b/src/test/recovery/pyt/test_029_stats_restart.py @@ -0,0 +1,305 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Port of src/test/recovery/t/029_stats_restart.pl. + +Tests statistics handling around restarts, including handling of crashes and +invalid stats files, as well as restoring stats after "normal" restarts. +""" + +import datetime +import pathlib +import shutil +from typing import NamedTuple + +# The counters each group of checks compares before and after a restart or a +# pg_stat_reset_shared(). Named rather than dicts so a mistyped field is an +# AttributeError here instead of a KeyError inside a comparison. + + +class IoStats(NamedTuple): + writes: int + reads: int + + +class CheckpointStats(NamedTuple): + count: int + reset: datetime.datetime + + +class WalStats(NamedTuple): + records: int + wal_bytes: int + reset: datetime.datetime + + +def test_stats_restart(create_pg, tmp_path): + node = create_pg("primary", allows_streaming=True, conf={"track_functions": "all"}) + + db_under_test = "test" + + # node.sql() runs on the default 'postgres' database, which is what the + # stats queries need; 'test' is only touched by the workload below, since + # connecting to it eagerly would create database stats and break the + # "stats were discarded" checks. node.sql() reconnects automatically after + # every (re)start, so no manual reconnect is needed. + + def have_stats(kind, dboid, objid): + return node.sql("SELECT pg_stat_have_stats($1, $2, $3)", kind, dboid, objid) + + def io_stats(context, obj, backend_type): + return IoStats( + *node.sql( + "SELECT writes, reads FROM pg_stat_io " + "WHERE context = $1 AND object = $2 AND backend_type = $3", + context, + obj, + backend_type, + ) + ) + + def checkpoint_stats(): + return CheckpointStats( + *node.sql( + "SELECT num_timed + num_requested, stats_reset FROM pg_stat_checkpointer" + ) + ) + + def wal_stats(): + return WalStats( + *node.sql("SELECT wal_records, wal_bytes, stats_reset FROM pg_stat_wal") + ) + + def trigger_funcrel_stat(): + # A fresh connection each time: it must connect to 'test' (generating + # the stats under test), and the previous one is dead after a restart. + node.connect(dbname=db_under_test).sql_batch( + "SELECT * FROM tab_stats_crash_discard_test1", + "SELECT func_stats_crash_discard1()", + "SELECT pg_stat_force_next_flush()", + ) + + # Check some WAL statistics after a fresh startup. The startup process + # should have done WAL reads, and initialization some WAL writes. + standalone = io_stats("init", "wal", "standalone backend") + startup = io_stats("normal", "wal", "startup") + + assert standalone.writes > 0, "startup: increased standalone backend IO writes" + assert startup.reads > 0, "startup: increased startup IO reads" + + # create test objects + node.sql(f"CREATE DATABASE {db_under_test}") + with node.connect(dbname=db_under_test) as dconn: + dconn.sql( + "CREATE TABLE tab_stats_crash_discard_test1 AS " + "SELECT generate_series(1,100) AS a" + ) + dconn.sql( + "CREATE FUNCTION func_stats_crash_discard1() RETURNS VOID AS 'select 2;' " + "LANGUAGE SQL IMMUTABLE" + ) + + # collect object oids + dboid = dconn.sql( + "SELECT oid FROM pg_database WHERE datname = $1", db_under_test + ) + funcoid = dconn.sql("SELECT 'func_stats_crash_discard1()'::regprocedure::oid") + tableoid = dconn.sql("SELECT 'tab_stats_crash_discard_test1'::regclass::oid") + + def assert_stats_exist(exist, phase): + """Assert the database, function and relation stats are all (not) there. + + Every phase below checks the same three kinds together, so the phase name + is an argument rather than a prefix repeated in each message. + """ + for kind, objid in ( + ("database", 0), + ("function", funcoid), + ("relation", tableoid), + ): + assert have_stats(kind, dboid, objid) == exist, f"{phase}: {kind} stats" + + # generate stats and flush them + trigger_funcrel_stat() + + # verify stats objects exist + assert_stats_exist(True, "initial") + + # regular shutdown + node.stop() + + # backup stats files + statsfile = tmp_path / "discard_stats1" + + assert not statsfile.exists(), "backup statsfile cannot already exist" + + og_stats = pathlib.Path(node.datadir) / "pg_stat" / "pgstat.stat" + + assert og_stats.is_file(), "origin stats file must exist" + + shutil.copy(og_stats, statsfile) + + ## test discarding of stats file after crash etc + + node.start() + + assert_stats_exist(True, "copy") + + node.stop("immediate") + + assert not og_stats.exists(), "no stats file should exist after immediate shutdown" + + # copy the old stats back to test we discard stats after crash restart + shutil.copy(statsfile, og_stats) + node.start() + + # stats should have been discarded + assert_stats_exist(False, "post immediate") + + # get rid of backup statsfile + statsfile.unlink() + + # generate new stats and flush them + trigger_funcrel_stat() + + assert_stats_exist(True, "post immediate, new") + + # regular shutdown + node.stop() + + ## check an invalid stats file is handled + + # normal startup and no issues despite invalid stats file + og_stats.write_text("ZZZZZZZZZZZZZ") + node.start() + + # no stats present due to invalid stats file + assert_stats_exist(False, "invalid_overwrite") + + ## check invalid stats file starting with valid contents, but followed by + ## invalid content is handled. + + trigger_funcrel_stat() + node.stop() + with open(og_stats, "a") as f: + f.write("XYZ") + node.start() + + assert_stats_exist(False, "invalid_append") + + ## checks related to stats persistency around restarts and resets + + # Ensure enough checkpoints to protect against races for test after reset, + # even on very slow machines. + node.sql("CHECKPOINT") + node.sql("CHECKPOINT") + + ## check checkpoint and wal stats are incremented due to restart + + ckpt_start = checkpoint_stats() + wal_start = wal_stats() + node.pg_ctl("restart") + + ckpt_restart = checkpoint_stats() + wal_restart = wal_stats() + + assert ckpt_start.count < ckpt_restart.count, ( + "post restart: increased checkpoint count" + ) + + assert wal_start.records < wal_restart.records, ( + "post restart: increased wal record count" + ) + + assert wal_start.wal_bytes < wal_restart.wal_bytes, ( + "post restart: increased wal bytes" + ) + + assert ckpt_start.reset == ckpt_restart.reset, ( + "post restart: checkpoint stats_reset equal" + ) + + assert wal_start.reset == wal_restart.reset, "post restart: wal stats_reset equal" + + ## Check that checkpoint stats are reset, WAL stats aren't affected + + node.sql("SELECT pg_stat_reset_shared('checkpointer')") + ckpt_reset = checkpoint_stats() + wal_ckpt_reset = wal_stats() + + assert ckpt_restart.count > ckpt_reset.count, ( + "post ckpt reset: checkpoint count smaller" + ) + + assert ckpt_start.reset < ckpt_reset.reset, "post ckpt reset: stats_reset newer" + + assert wal_restart.records <= wal_ckpt_reset.records, ( + "post ckpt reset: wal record count not affected by reset" + ) + + assert wal_start.reset == wal_ckpt_reset.reset, ( + "post ckpt reset: wal stats_reset equal" + ) + + ## check that checkpoint stats stay reset after restart + + node.pg_ctl("restart") + ckpt_restart_reset = checkpoint_stats() + wal_restart2 = wal_stats() + + assert ckpt_restart_reset.count < ckpt_restart.count, ( + "post ckpt reset & restart: checkpoint still reset" + ) + + assert ckpt_restart_reset.reset == ckpt_reset.reset, ( + "post ckpt reset & restart: stats_reset same" + ) + + assert wal_ckpt_reset.records < wal_restart2.records, ( + "post ckpt reset & restart: increased wal record count" + ) + + assert wal_ckpt_reset.wal_bytes < wal_restart2.wal_bytes, ( + "post ckpt reset & restart: increased wal bytes" + ) + + assert wal_start.reset == wal_restart2.reset, ( + "post ckpt reset & restart: wal stats_reset equal" + ) + + ## check WAL stats stay reset + + node.sql("SELECT pg_stat_reset_shared('wal')") + wal_reset = wal_stats() + + assert wal_reset.records < wal_restart2.records, ( + "post wal reset: smaller record count" + ) + + assert wal_reset.wal_bytes < wal_restart2.wal_bytes, "post wal reset: smaller bytes" + assert wal_reset.reset > wal_restart2.reset, "post wal reset: newer stats_reset" + + node.pg_ctl("restart") + wal_reset_restart = wal_stats() + + assert wal_reset_restart.records < wal_restart2.records, ( + "post wal reset & restart: smaller record count" + ) + + assert wal_reset.wal_bytes < wal_restart2.wal_bytes, ( + "post wal reset & restart: smaller bytes" + ) + + assert wal_reset.reset > wal_restart2.reset, ( + "post wal reset & restart: newer stats_reset" + ) + + # An immediate restart bumps the WAL stats_reset timestamp. + node.stop("immediate") + node.start() + wal_restart_immediate = wal_stats() + + assert wal_reset_restart.reset < wal_restart_immediate.reset, ( + "post immediate restart: reset timestamp is new" + ) + + node.stop() diff --git a/src/test/recovery/t/029_stats_restart.pl b/src/test/recovery/t/029_stats_restart.pl deleted file mode 100644 index cdc427dbc78..00000000000 --- a/src/test/recovery/t/029_stats_restart.pl +++ /dev/null @@ -1,376 +0,0 @@ -# Copyright (c) 2021-2026, PostgreSQL Global Development Group - -# Tests statistics handling around restarts, including handling of crashes and -# invalid stats files, as well as restoring stats after "normal" restarts. - -use strict; -use warnings FATAL => 'all'; -use PostgreSQL::Test::Cluster; -use PostgreSQL::Test::Utils; -use Test::More; -use File::Copy; - -my $node = PostgreSQL::Test::Cluster->new('primary'); -$node->init(allows_streaming => 1); -$node->append_conf('postgresql.conf', "track_functions = 'all'"); -$node->start; - -my $connect_db = 'postgres'; -my $db_under_test = 'test'; - -my $sect = "startup"; - -# Check some WAL statistics after a fresh startup. The startup process -# should have done WAL reads, and initialization some WAL writes. -my $standalone_io_stats = io_stats('init', 'wal', 'standalone backend'); -my $startup_io_stats = io_stats('normal', 'wal', 'startup'); -cmp_ok( - '0', '<', - $standalone_io_stats->{writes}, - "$sect: increased standalone backend IO writes"); -cmp_ok( - '0', '<', - $startup_io_stats->{reads}, - "$sect: increased startup IO reads"); - -# create test objects -$node->safe_psql($connect_db, "CREATE DATABASE $db_under_test"); -$node->safe_psql($db_under_test, - "CREATE TABLE tab_stats_crash_discard_test1 AS SELECT generate_series(1,100) AS a" -); -$node->safe_psql($db_under_test, - "CREATE FUNCTION func_stats_crash_discard1() RETURNS VOID AS 'select 2;' LANGUAGE SQL IMMUTABLE" -); - -# collect object oids -my $dboid = $node->safe_psql($db_under_test, - "SELECT oid FROM pg_database WHERE datname = '$db_under_test'"); -my $funcoid = $node->safe_psql($db_under_test, - "SELECT 'func_stats_crash_discard1()'::regprocedure::oid"); -my $tableoid = $node->safe_psql($db_under_test, - "SELECT 'tab_stats_crash_discard_test1'::regclass::oid"); - -# generate stats and flush them -trigger_funcrel_stat(); - -# verify stats objects exist -$sect = "initial"; -is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist"); -is(have_stats('function', $dboid, $funcoid), - 't', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), - 't', "$sect: relation stats do exist"); - -# regular shutdown -$node->stop(); - -# backup stats files -my $statsfile = $PostgreSQL::Test::Utils::tmp_check . '/' . "discard_stats1"; -ok(!-f "$statsfile", "backup statsfile cannot already exist"); - -my $datadir = $node->data_dir(); -my $og_stats = "$datadir/pg_stat/pgstat.stat"; -ok(-f "$og_stats", "origin stats file must exist"); -copy($og_stats, $statsfile) or die "Copy failed: $!"; - - -## test discarding of stats file after crash etc - -$node->start; - -$sect = "copy"; -is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist"); -is(have_stats('function', $dboid, $funcoid), - 't', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), - 't', "$sect: relation stats do exist"); - -$node->stop('immediate'); - -ok(!-f "$og_stats", "no stats file should exist after immediate shutdown"); - -# copy the old stats back to test we discard stats after crash restart -copy($statsfile, $og_stats) or die "Copy failed: $!"; - -$node->start; - -# stats should have been discarded -$sect = "post immediate"; -is(have_stats('database', $dboid, 0), 'f', "$sect: db stats do not exist"); -is(have_stats('function', $dboid, $funcoid), - 'f', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), - 'f', "$sect: relation stats do not exist"); - -# get rid of backup statsfile -unlink $statsfile or die "cannot unlink $statsfile $!"; - - -# generate new stats and flush them -trigger_funcrel_stat(); - -$sect = "post immediate, new"; -is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist"); -is(have_stats('function', $dboid, $funcoid), - 't', "$sect: function stats do exist"); -is(have_stats('relation', $dboid, $tableoid), - 't', "$sect: relation stats do exist"); - -# regular shutdown -$node->stop(); - - -## check an invalid stats file is handled - -overwrite_file($og_stats, "ZZZZZZZZZZZZZ"); - -# normal startup and no issues despite invalid stats file -$node->start; - -# no stats present due to invalid stats file -$sect = "invalid_overwrite"; -is(have_stats('database', $dboid, 0), 'f', "$sect: db stats do not exist"); -is(have_stats('function', $dboid, $funcoid), - 'f', "$sect: function stats do not exist"); -is(have_stats('relation', $dboid, $tableoid), - 'f', "$sect: relation stats do not exist"); - - -## check invalid stats file starting with valid contents, but followed by -## invalid content is handled. - -trigger_funcrel_stat(); -$node->stop; -append_file($og_stats, "XYZ"); -$node->start; - -$sect = "invalid_append"; -is(have_stats('database', $dboid, 0), 'f', "$sect: db stats do not exist"); -is(have_stats('function', $dboid, $funcoid), - 'f', "$sect: function stats do not exist"); -is(have_stats('relation', $dboid, $tableoid), - 'f', "$sect: relation stats do not exist"); - - -## checks related to stats persistency around restarts and resets - -# Ensure enough checkpoints to protect against races for test after reset, -# even on very slow machines. -$node->safe_psql($connect_db, "CHECKPOINT; CHECKPOINT;"); - - -## check checkpoint and wal stats are incremented due to restart - -my $ckpt_start = checkpoint_stats(); -my $wal_start = wal_stats(); -$node->restart; - -$sect = "post restart"; -my $ckpt_restart = checkpoint_stats(); -my $wal_restart = wal_stats(); - -cmp_ok( - $ckpt_start->{count}, '<', - $ckpt_restart->{count}, - "$sect: increased checkpoint count"); -cmp_ok( - $wal_start->{records}, '<', - $wal_restart->{records}, - "$sect: increased wal record count"); -cmp_ok($wal_start->{bytes}, '<', $wal_restart->{bytes}, - "$sect: increased wal bytes"); -is( $ckpt_start->{reset}, - $ckpt_restart->{reset}, - "$sect: checkpoint stats_reset equal"); -is($wal_start->{reset}, $wal_restart->{reset}, - "$sect: wal stats_reset equal"); - - -## Check that checkpoint stats are reset, WAL stats aren't affected - -$node->safe_psql($connect_db, "SELECT pg_stat_reset_shared('checkpointer')"); - -$sect = "post ckpt reset"; -my $ckpt_reset = checkpoint_stats(); -my $wal_ckpt_reset = wal_stats(); - -cmp_ok($ckpt_restart->{count}, - '>', $ckpt_reset->{count}, "$sect: checkpoint count smaller"); -cmp_ok($ckpt_start->{reset}, 'lt', $ckpt_reset->{reset}, - "$sect: stats_reset newer"); - -cmp_ok( - $wal_restart->{records}, - '<=', - $wal_ckpt_reset->{records}, - "$sect: wal record count not affected by reset"); -is( $wal_start->{reset}, - $wal_ckpt_reset->{reset}, - "$sect: wal stats_reset equal"); - - -## check that checkpoint stats stay reset after restart - -$node->restart; - -$sect = "post ckpt reset & restart"; -my $ckpt_restart_reset = checkpoint_stats(); -my $wal_restart2 = wal_stats(); - -# made sure above there's enough checkpoints that this will be stable even on slow machines -cmp_ok( - $ckpt_restart_reset->{count}, - '<', - $ckpt_restart->{count}, - "$sect: checkpoint still reset"); -is($ckpt_restart_reset->{reset}, - $ckpt_reset->{reset}, "$sect: stats_reset same"); - -cmp_ok( - $wal_ckpt_reset->{records}, - '<', - $wal_restart2->{records}, - "$sect: increased wal record count"); -cmp_ok( - $wal_ckpt_reset->{bytes}, - '<', - $wal_restart2->{bytes}, - "$sect: increased wal bytes"); -is( $wal_start->{reset}, - $wal_restart2->{reset}, - "$sect: wal stats_reset equal"); - - -## check WAL stats stay reset - -$node->safe_psql($connect_db, "SELECT pg_stat_reset_shared('wal')"); - -$sect = "post wal reset"; -my $wal_reset = wal_stats(); - -cmp_ok( - $wal_reset->{records}, '<', - $wal_restart2->{records}, - "$sect: smaller record count"); -cmp_ok( - $wal_reset->{bytes}, '<', - $wal_restart2->{bytes}, - "$sect: smaller bytes"); -cmp_ok( - $wal_reset->{reset}, 'gt', - $wal_restart2->{reset}, - "$sect: newer stats_reset"); - -$node->restart; - -$sect = "post wal reset & restart"; -my $wal_reset_restart = wal_stats(); - -# enough WAL generated during prior tests and initdb to make this not racy -cmp_ok( - $wal_reset_restart->{records}, - '<', - $wal_restart2->{records}, - "$sect: smaller record count"); -cmp_ok( - $wal_reset->{bytes}, '<', - $wal_restart2->{bytes}, - "$sect: smaller bytes"); -cmp_ok( - $wal_reset->{reset}, 'gt', - $wal_restart2->{reset}, - "$sect: newer stats_reset"); - -$node->stop('immediate'); -$node->start; - -$sect = "post immediate restart"; -my $wal_restart_immediate = wal_stats(); - -cmp_ok( - $wal_reset_restart->{reset}, - 'lt', - $wal_restart_immediate->{reset}, - "$sect: reset timestamp is new"); - -$node->stop; -done_testing(); - -sub trigger_funcrel_stat -{ - $node->safe_psql( - $db_under_test, q[ - SELECT * FROM tab_stats_crash_discard_test1; - SELECT func_stats_crash_discard1(); - SELECT pg_stat_force_next_flush();]); -} - -sub have_stats -{ - my ($kind, $dboid, $objid) = @_; - - return $node->safe_psql($connect_db, - "SELECT pg_stat_have_stats('$kind', $dboid, $objid)"); -} - -sub overwrite_file -{ - my ($filename, $str) = @_; - open my $fh, ">", $filename - or die "could not overwrite \"$filename\": $!"; - print $fh $str; - close $fh; - return; -} - -sub append_file -{ - my ($filename, $str) = @_; - open my $fh, ">>", $filename - or die "could not append to \"$filename\": $!"; - print $fh $str; - close $fh; - return; -} - -sub checkpoint_stats -{ - my %results; - - $results{count} = $node->safe_psql($connect_db, - "SELECT num_timed + num_requested FROM pg_stat_checkpointer"); - $results{reset} = $node->safe_psql($connect_db, - "SELECT stats_reset FROM pg_stat_checkpointer"); - - return \%results; -} - -sub wal_stats -{ - my %results; - $results{records} = - $node->safe_psql($connect_db, "SELECT wal_records FROM pg_stat_wal"); - $results{bytes} = - $node->safe_psql($connect_db, "SELECT wal_bytes FROM pg_stat_wal"); - $results{reset} = - $node->safe_psql($connect_db, "SELECT stats_reset FROM pg_stat_wal"); - - return \%results; -} - -sub io_stats -{ - my ($context, $object, $backend_type) = @_; - my %results; - - $results{writes} = $node->safe_psql( - $connect_db, qq{SELECT writes FROM pg_stat_io - WHERE context = '$context' AND object = '$object' AND - backend_type = '$backend_type'}); - $results{reads} = $node->safe_psql( - $connect_db, qq{SELECT reads FROM pg_stat_io - WHERE context = '$context' AND object = '$object' AND - backend_type = '$backend_type'}); - - return \%results; -} -- 2.54.0