]> git.ipfire.org Git - thirdparty/postgresql.git/commitdiff
Test that VM clear registers VM buffers
authorMelanie Plageman <melanieplageman@gmail.com>
Wed, 15 Jul 2026 21:37:34 +0000 (17:37 -0400)
committerMelanie Plageman <melanieplageman@gmail.com>
Wed, 15 Jul 2026 21:37:34 +0000 (17:37 -0400)
The WAL summarizer only tracks registered buffers, so unregistered VM
clears are ommitted from incremental backups, corrupting the restored
visibility map. Test those cases are now fixed.

Author: Melanie Plageman <melanieplageman@gmail.com>
Reviewed-by: Andrey Borodin <x4mmm@yandex-team.ru>
Discussion: https://postgr.es/m/oqcsevg35xjan2327x5kdfth6q4fgeqboxfo3v3imeyih2uiny%406sez5dzxl6nt
Backpatch-through: 17

src/bin/pg_combinebackup/Makefile
src/bin/pg_combinebackup/meson.build
src/bin/pg_combinebackup/t/012_vm_consistency.pl [new file with mode: 0644]

index 33a1f4483bfbdc5177773dd56647c3b6a4256f2a..bdba0353412b7b5aca53b5885aff92131739219c 100644 (file)
@@ -12,6 +12,8 @@
 PGFILEDESC = "pg_combinebackup - combine incremental backups"
 PGAPPICON=win32
 
+EXTRA_INSTALL=contrib/pg_visibility
+
 subdir = src/bin/pg_combinebackup
 top_builddir = ../../..
 include $(top_builddir)/src/Makefile.global
index bbc4c5735ba4f450499bb73677518b1d799feb44..757b78e2fe8e4e27a37cd2736d2478c7f29fa5eb 100644 (file)
@@ -39,6 +39,7 @@ tests += {
       't/009_no_full_file.pl',
       't/010_hardlink.pl',
       't/011_ib_truncation.pl',
+      't/012_vm_consistency.pl',
     ],
   }
 }
diff --git a/src/bin/pg_combinebackup/t/012_vm_consistency.pl b/src/bin/pg_combinebackup/t/012_vm_consistency.pl
new file mode 100644 (file)
index 0000000..6bf47eb
--- /dev/null
@@ -0,0 +1,258 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Test that heap operations clearing visibility map bits (INSERT, UPDATE,
+# DELETE, SELECT FOR UPDATE, COPY) correctly register visibility map buffers,
+# since incremental backups rely on the WAL summarizer, which only tracks
+# registered buffers.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $tempdir = PostgreSQL::Test::Utils::tempdir_short();
+my $mode = $ENV{PG_TEST_PG_COMBINEBACKUP_MODE} || '--copy';
+
+# Set up primary with WAL summarization enabled.
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init(allows_streaming => 1);
+$primary->append_conf('postgresql.conf', <<EOF);
+summarize_wal = on
+autovacuum = off
+EOF
+$primary->start;
+
+$primary->safe_psql('postgres', q{CREATE EXTENSION pg_visibility});
+
+my @tests = (
+       {
+               label => 'INSERT',
+               table => 'vm_insert_test',
+               setup => q{CREATE TABLE vm_insert_test (id int);
+                       INSERT INTO vm_insert_test DEFAULT VALUES;},
+               modify => q{INSERT INTO vm_insert_test VALUES (1)},
+               visible_op => '<',
+               frozen_op => '<',
+       },
+       {
+               label => 'DELETE',
+               table => 'vm_delete_test',
+               setup => q{CREATE TABLE vm_delete_test (id int);
+                       INSERT INTO vm_delete_test VALUES (1), (2);},
+               modify => q{DELETE FROM vm_delete_test WHERE id = 1},
+               visible_op => '<',
+               frozen_op => '<',
+       },
+       {
+               label => 'UPDATE',
+               table => 'vm_update_test',
+               # Include both same-page and cross-page updates. val is stored PLAIN
+               # so the large update stays inline.
+               setup => q{CREATE TABLE vm_update_test (id INT, val TEXT);
+                       ALTER TABLE vm_update_test ALTER COLUMN val SET STORAGE PLAIN;
+                       INSERT INTO vm_update_test VALUES (1, 'same page'), (2, 'cross page');
+                       INSERT INTO vm_update_test SELECT i, repeat('a', 200)
+                               FROM generate_series(3, 70) i;},
+               modify => q{UPDATE vm_update_test SET id = 0 WHERE id = 1;
+                       UPDATE vm_update_test SET val = repeat('b', 4000) WHERE id = 2;},
+               # Confirm the small update stays on the same heap page while the large
+               # update relocates the tuple to a different heap page.
+               ctid_checks => [
+                       {
+                               before => 'id = 1',
+                               after => 'id = 0',
+                               same => 1,
+                               desc => 'small update stays on the same heap page',
+                       },
+                       {
+                               before => 'id = 2',
+                               after => 'id = 2',
+                               same => 0,
+                               desc => 'large update moves the tuple to a different heap page',
+                       },
+               ],
+               visible_op => '<',
+               frozen_op => '<',
+       },
+       {
+               label => 'LOCK',
+               table => 'vm_lock_test',
+               setup => q{CREATE TABLE vm_lock_test (id int);
+                       INSERT INTO vm_lock_test VALUES (1), (2);},
+               modify => q{SELECT * FROM vm_lock_test WHERE id = 1 FOR UPDATE},
+               visible_op => '==',
+               frozen_op => '<',
+       },
+       {
+               label => 'COPY',
+               table => 'vm_copy_test',
+               setup => q{CREATE TABLE vm_copy_test (id int);
+                       INSERT INTO vm_copy_test DEFAULT VALUES;},
+               modify => q{COPY vm_copy_test FROM PROGRAM 'echo 42'},
+               visible_op => '<',
+               frozen_op => '<',
+       },
+);
+
+sub get_vm_summary
+{
+       my ($node, $table) = @_;
+       my $result = $node->safe_psql('postgres',
+               "SELECT all_visible, all_frozen FROM pg_visibility_map_summary('$table')");
+       my @vals = split(/\|/, $result);
+       return @vals;
+}
+
+# Return the heap block number of the (single) row matching $where. The ctid
+# is "(block,offset)"; casting it through point lets us pull out the block.
+sub heap_block
+{
+       my ($node, $table, $where) = @_;
+       return $node->safe_psql('postgres',
+               "SELECT (ctid::text::point)[0]::int FROM $table WHERE $where");
+}
+
+# Confirm VACUUM (FREEZE) set VM bits before testing whether later heap
+# modifications clear those bits and are captured by incremental backup. We
+# could perhaps be more exact than > 0, but the coarseness attempts to avoid
+# test flakes.
+sub check_vacuumed_vm
+{
+       my ($node, $test) = @_;
+       my ($all_visible, $all_frozen) = get_vm_summary($node, $test->{table});
+
+       cmp_ok($all_visible, '>', 0,
+               "$test->{label} test: pages are all-visible after vacuum");
+       cmp_ok($all_frozen, '>', 0,
+               "$test->{label} test: pages are all-frozen after vacuum");
+
+       return ($all_visible, $all_frozen);
+}
+
+# Check the VM bit counts after a heap modification against the post-vacuum
+# baseline. Most operations clear both bits; tuple locking clears all-frozen
+# without clearing all-visible.
+sub check_modified_vm
+{
+       my ($node, $test) = @_;
+       my ($post_visible, $post_frozen) = get_vm_summary($node, $test->{table});
+
+       cmp_ok($post_visible, $test->{visible_op}, $test->{pre_visible},
+               "$test->{label} test: all-visible state after modification");
+       cmp_ok($post_frozen, $test->{frozen_op}, $test->{pre_frozen},
+               "$test->{label} test: all-frozen state after modification");
+
+       return ($post_visible, $post_frozen);
+}
+
+# Verify the combined backup restored VM state exactly as it exists on the
+# primary, and ask pg_visibility to check that visible tuples are consistent.
+sub validate_restored_vm
+{
+       my ($restored, $test) = @_;
+
+       my ($primary_visible, $primary_frozen) =
+         get_vm_summary($primary, $test->{table});
+       my ($restored_visible, $restored_frozen) =
+         get_vm_summary($restored, $test->{table});
+
+       is($restored_visible, $primary_visible,
+               "$test->{label} test: restored all_visible count matches primary");
+       is($restored_frozen, $primary_frozen,
+               "$test->{label} test: restored all_frozen count matches primary");
+
+       my $corrupt_tids = $restored->safe_psql('postgres',
+               "SELECT count(*) FROM pg_check_visible('$test->{table}')");
+       is($corrupt_tids, '0',
+               "$test->{label} test: no VM corruption detected by pg_check_visible");
+}
+
+# Create and populate the tables, then vacuum freeze them to set the VM bits
+foreach my $test (@tests)
+{
+       $primary->safe_psql('postgres', $test->{setup});
+       $primary->safe_psql('postgres', "VACUUM (FREEZE) $test->{table}");
+       ($test->{pre_visible}, $test->{pre_frozen}) =
+         check_vacuumed_vm($primary, $test);
+}
+
+# Take a full backup
+my $full_name = 'full';
+my $full_path = $primary->backup_dir . "/$full_name";
+$primary->command_ok(
+       [
+               'pg_basebackup', '--no-sync',
+               '--pgdata'      => $full_path,
+               '--checkpoint'  => 'fast',
+       ],
+       'full backup');
+
+# Modify the tables and check that the VM bits are as expected for that test
+# after the specified modification.
+foreach my $test (@tests)
+{
+       # Record the heap block of any rows whose same-/cross-page movement we
+       # want to verify, before the modification relocates them.
+       foreach my $check (@{ $test->{ctid_checks} // [] })
+       {
+               $check->{before_blk} =
+                 heap_block($primary, $test->{table}, $check->{before});
+       }
+
+       $primary->safe_psql('postgres', $test->{modify});
+       ($test->{post_visible}, $test->{post_frozen}) =
+         check_modified_vm($primary, $test);
+
+       # Confirm each checked row did (or did not) move to a different heap page.
+       foreach my $check (@{ $test->{ctid_checks} // [] })
+       {
+               my $after_blk = heap_block($primary, $test->{table}, $check->{after});
+               if ($check->{same})
+               {
+                       is($after_blk, $check->{before_blk},
+                               "$test->{label} test: $check->{desc}");
+               }
+               else
+               {
+                       isnt($after_blk, $check->{before_blk},
+                               "$test->{label} test: $check->{desc}");
+               }
+       }
+}
+
+# Take an incremental backup. This will have the changes made in the
+# modification step.
+my $incr_name = 'incr';
+my $incr_path = $primary->backup_dir . "/$incr_name";
+$primary->command_ok(
+       [
+               'pg_basebackup', '--no-sync',
+               '--pgdata'      => $incr_path,
+               '--checkpoint'  => 'fast',
+               '--incremental' => $full_path . '/backup_manifest',
+       ],
+       'incremental backup');
+
+# Start a server from a combined backup composed of the incremental and full
+# backup.
+my $restored = PostgreSQL::Test::Cluster->new('restored');
+$restored->init_from_backup($primary, $incr_name,
+       combine_with_prior => [$full_name],
+       combine_mode       => $mode);
+$restored->append_conf('postgresql.conf', <<EOF);
+autovacuum = off
+EOF
+$restored->start;
+$restored->safe_psql('postgres', q{CREATE EXTENSION IF NOT EXISTS pg_visibility});
+
+# Confirm that the restored server's visibility map matches the original server
+foreach my $test (@tests)
+{
+       validate_restored_vm($restored, $test);
+}
+
+$restored->stop;
+$primary->stop;
+
+done_testing();