Hi Jan,

Thanks for digging into this. You're right, the static count in the test is
brittle.

The updated TAP test addressed your concern.

Thanks,
Shihao
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index b6ac9dd07a7..6ee9462bef0 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -69,6 +69,7 @@ tests += {
       't/058_shutdown_crash_restart.pl',
       't/059_remote_apply_status_interval.pl',
       't/060_multixact_crashed_updater.pl',
+      't/062_skip_fpi.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/062_skip_fpi.pl 
b/src/test/recovery/t/062_skip_fpi.pl
new file mode 100644
index 00000000000..bbe863dc795
--- /dev/null
+++ b/src/test/recovery/t/062_skip_fpi.pl
@@ -0,0 +1,85 @@
+
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Recovery prefetch should skip a block reference that follows a pending
+# full page image of that block (skip_fpi), but not one whose image has
+# already been replayed.
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Without checksums and wal_log_hints, the image comes with the first update
+# of each page, not with a hint bit record written by an earlier scan.
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init(
+       has_archiving => 1,
+       allows_streaming => 1,
+       no_data_checksums => 1);
+$primary->append_conf('postgresql.conf',
+       "autovacuum = off\nwal_log_hints = off\n");
+$primary->start;
+
+my $ret = $primary->psql('postgres', 'ALTER SYSTEM SET recovery_prefetch = on',
+       on_error_stop => 0);
+plan skip_all => 'prefetching is not supported on this platform' if $ret != 0;
+
+# One row per page.  Each update of a row references its page twice, once
+# for the image and once more.
+$primary->safe_psql(
+       'postgres', q(
+       CREATE TABLE t (id int, v int, pad text) WITH (fillfactor = 50);
+       CREATE TABLE u (id int, v int, pad text) WITH (fillfactor = 50);
+       ALTER TABLE t ALTER COLUMN pad SET STORAGE PLAIN;
+       ALTER TABLE u ALTER COLUMN pad SET STORAGE PLAIN;
+       INSERT INTO t SELECT i, 0, repeat('x', 2700) FROM generate_series(1, 
100) i;
+       INSERT INTO u SELECT i, 0, repeat('x', 2700) FROM generate_series(1, 
100) i;
+       CREATE TABLE filler (a int, b text);
+       CHECKPOINT;));
+my $pages = $primary->safe_psql('postgres',
+       "SELECT pg_relation_size('t') / current_setting('block_size')::int");
+$primary->backup('backup');
+
+# Write the images of t, then enough WAL for them to be replayed.  Then
+# update each row of u, which writes an image, followed by the same row of
+# t, whose image was replayed long ago.
+$primary->safe_psql(
+       'postgres', q(
+       UPDATE t SET v = 1;
+       INSERT INTO filler SELECT i, md5(i::text) FROM generate_series(1, 
200000) i;
+       DO $$ BEGIN
+         FOR i IN 1..100 LOOP
+           UPDATE u SET v = 1 WHERE id = i;
+           UPDATE t SET v = 2 WHERE id = i;
+         END LOOP;
+       END $$;));
+$primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+my $end_lsn = $primary->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+my $walfile = $primary->safe_psql('postgres',
+       "SELECT pg_walfile_name('$end_lsn'::pg_lsn - 1)");
+$primary->poll_query_until('postgres',
+       "SELECT last_archived_wal >= '$walfile' FROM pg_stat_archiver")
+  or die "timed out waiting for WAL to be archived";
+
+my $standby = PostgreSQL::Test::Cluster->new('standby');
+$standby->init_from_backup($primary, 'backup', has_restoring => 1);
+$standby->append_conf('postgresql.conf',
+       "recovery_prefetch = on\nmaintenance_io_concurrency = 32\n");
+$standby->start;
+$standby->poll_query_until('postgres',
+       "SELECT pg_last_wal_replay_lsn() >= '$end_lsn'")
+  or die "timed out waiting for replay";
+
+# One skipped reference per image of t and of u.  Skipping the later
+# references to t would add several per page.
+my $skip_fpi = $standby->safe_psql('postgres',
+       'SELECT skip_fpi FROM pg_stat_recovery_prefetch');
+note "skip_fpi = $skip_fpi, pages = $pages";
+cmp_ok($skip_fpi, '>=', 1.5 * $pages,
+       'references after a pending FPI are skipped');
+cmp_ok($skip_fpi, '<=', 2.5 * $pages,
+       'references after a replayed FPI are not skipped');
+
+done_testing();

Reply via email to