From 2c559fa2e2116480d3a1042a2140773a707331bf Mon Sep 17 00:00:00 2001
From: shihao zhong <zhong950419@gmail.com>
Date: Tue, 20 Oct 2026 07:44:09 -0400
Subject: [PATCH v2 2/2] Add a test for the WAL receipt time used by recovery
 conflicts

Check that a standby keeping up with a busy primary waits for
max_standby_streaming_delay before canceling a conflicting query, and
that a standby that is really behind cancels it at once.

Discussion: https://postgr.es/m/ymyivl5bw7qwd3bhduxmv5kytsl6q4nstjtledw5l6nr7sdwcw@nl7egmss5l2m
---
 src/test/recovery/meson.build                 |   1 +
 .../recovery/t/062_standby_receipt_time.pl    | 118 ++++++++++++++++++
 2 files changed, 119 insertions(+)
 create mode 100644 src/test/recovery/t/062_standby_receipt_time.pl

diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index b6ac9dd07a7..fedad654e64 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_standby_receipt_time.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/062_standby_receipt_time.pl b/src/test/recovery/t/062_standby_receipt_time.pl
new file mode 100644
index 00000000000..85dc68eb671
--- /dev/null
+++ b/src/test/recovery/t/062_standby_receipt_time.pl
@@ -0,0 +1,118 @@
+
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Check that max_standby_streaming_delay is measured from a sane WAL receipt
+# time.  A standby that keeps up with a busy primary must give conflicting
+# queries their grace period, while a standby that is really behind must
+# cancel them without waiting.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node_primary = PostgreSQL::Test::Cluster->new('primary');
+$node_primary->init(allows_streaming => 1);
+$node_primary->append_conf('postgresql.conf', 'autovacuum = off');
+$node_primary->start;
+
+$node_primary->safe_psql('postgres', 'CREATE TABLE conflict_tab (a int)');
+# The load runs in its own database so that it does not hold back the removal
+# horizon of conflict_tab.
+$node_primary->safe_psql('postgres', 'CREATE DATABASE loaddb');
+$node_primary->safe_psql(
+	'loaddb', q[
+CREATE TABLE load_tab (a int);
+CREATE PROCEDURE gen_load(n int) LANGUAGE plpgsql AS $$
+BEGIN
+  FOR i IN 1..n LOOP
+    INSERT INTO load_tab VALUES (i);
+    COMMIT;
+    PERFORM pg_sleep(0.05);
+  END LOOP;
+END $$;
+]);
+
+$node_primary->backup('backup');
+my $node_standby = PostgreSQL::Test::Cluster->new('standby');
+$node_standby->init_from_backup($node_primary, 'backup',
+	has_streaming => 1);
+# The apply delay makes walreceiver flush several chunks while the startup
+# process is replaying, which is what used to leave the receipt time stale.
+$node_standby->append_conf(
+	'postgresql.conf', q[
+recovery_min_apply_delay = 1s
+max_standby_streaming_delay = 5s
+hot_standby_feedback = off
+log_recovery_conflict_waits = on
+deadlock_timeout = 1s
+]);
+$node_standby->start;
+
+my $load = $node_primary->background_psql('loaddb');
+my $standby_session = $node_standby->background_psql('postgres',
+	on_error_stop => 0);
+
+# Generate a snapshot conflict while a standby transaction holds a snapshot,
+# and return the standby log written until the session was canceled.
+sub run_conflict
+{
+	my ($before_conflict, $after_conflict) = @_;
+
+	$node_primary->safe_psql('postgres',
+		'INSERT INTO conflict_tab SELECT generate_series(1, 100)');
+	$node_primary->wait_for_replay_catchup($node_standby);
+
+	$standby_session->query_safe(
+		q[BEGIN ISOLATION LEVEL REPEATABLE READ;
+		  SELECT count(*) FROM conflict_tab;]);
+
+	my $log_offset = -s $node_standby->logfile;
+	$before_conflict->();
+	$node_primary->safe_psql('postgres', 'DELETE FROM conflict_tab');
+	$node_primary->safe_psql('postgres', 'VACUUM conflict_tab');
+	$after_conflict->();
+
+	$node_standby->wait_for_log(qr/conflict with recovery/, $log_offset);
+	$standby_session->reconnect_and_clear();
+	$node_primary->wait_for_replay_catchup($node_standby);
+	return slurp_file($node_standby->logfile, $log_offset);
+}
+
+# The primary commits 20 times per second for longer than the grace period
+# before the conflict, while the standby stays about a second behind.
+$load->query_until(qr/load started/, "\\echo load started\nCALL gen_load(200);\n");
+my $log = run_conflict(sub { sleep(7); }, sub { });
+like(
+	$log,
+	qr/recovery still waiting after .*recovery conflict on snapshot.*conflict with recovery/s,
+	'standby keeping up with a busy primary waits before canceling');
+
+# Now the standby falls behind by more than the grace period, so the
+# conflicting query must be canceled without waiting.
+$load->query_safe('SELECT 1');
+$node_primary->wait_for_replay_catchup($node_standby);
+$load->query_until(qr/load started/, "\\echo load started\nCALL gen_load(200);\n");
+$log = run_conflict(
+	sub {
+		$node_standby->safe_psql('postgres', 'SELECT pg_wal_replay_pause()');
+		$node_standby->poll_query_until('postgres',
+			"SELECT pg_get_wal_replay_pause_state() = 'paused'")
+		  or die "timed out waiting for replay to pause";
+	},
+	sub {
+		sleep(7);
+		# This session holds a snapshot from before the conflict too, so it
+		# may be canceled as soon as replay resumes.  The resume itself has
+		# taken effect by then, so ignore the error.
+		$node_standby->psql('postgres', 'SELECT pg_wal_replay_resume()');
+	});
+like($log, qr/conflict with recovery/, 'lagging standby cancels query');
+unlike($log, qr/recovery still waiting/,
+	'lagging standby cancels query without waiting');
+
+$load->quit;
+$standby_session->quit;
+
+done_testing();
-- 
2.37.1 (Apple Git-137.1)

