From 19c94660f8eb7b4e01a97760df24ead07021ea6a Mon Sep 17 00:00:00 2001
From: Melanie Plageman <melanieplageman@gmail.com>
Date: Thu, 23 Apr 2026 11:13:23 -0400
Subject: [PATCH v2_PG18 4/4] Test that VM clear registers VM buffers

WAL summarizer only includes registered buffers, so you could end up
with data corruption in incremental backups if VM clears were meant to
be covered by the incremental backup.

Author: Melanie Plageman <melanieplageman@gmail.com>
Discussion: https://postgr.es/m/oqcsevg35xjan2327x5kdfth6q4fgeqboxfo3v3imeyih2uiny%406sez5dzxl6nt
Backpatch-through: 17
---
 src/bin/pg_combinebackup/meson.build          |   1 +
 .../pg_combinebackup/t/012_vm_consistency.pl  | 205 ++++++++++++++++++
 2 files changed, 206 insertions(+)
 create mode 100644 src/bin/pg_combinebackup/t/012_vm_consistency.pl

diff --git a/src/bin/pg_combinebackup/meson.build b/src/bin/pg_combinebackup/meson.build
index bbc4c5735ba..757b78e2fe8 100644
--- a/src/bin/pg_combinebackup/meson.build
+++ b/src/bin/pg_combinebackup/meson.build
@@ -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
index 00000000000..7b9c577d19a
--- /dev/null
+++ b/src/bin/pg_combinebackup/t/012_vm_consistency.pl
@@ -0,0 +1,205 @@
+# Copyright (c) 2021-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(has_archiving => 1, 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.
+		setup => q{CREATE TABLE vm_update_test (id INT, val TEXT);
+			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', 8000) WHERE id = 2;},
+		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;
+}
+
+# Confirm VACUUM (FREEZE) set VM bits before testing whether later heap
+# modifications clear those bits and are captured by incremental backup.
+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)
+{
+	$primary->safe_psql('postgres', $test->{modify});
+	($test->{post_visible}, $test->{post_frozen}) =
+	  check_modified_vm($primary, $test);
+}
+
+# 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();
-- 
2.43.0

