From e5977336e4fd809960661fe62f461d515738c3b3 Mon Sep 17 00:00:00 2001
From: "chee.wooson" <chee.wooson@gmail.com>
Date: Fri, 11 Sep 2026 10:34:32 +0800
Subject: [PATCH v3 1/2] Add TAP test for recovery conflicts from imported
 snapshots

Add injection points around the interval between collecting a snapshot
recovery-conflict wait list and finishing the wait for a listed VXID.  Use
them in a TAP test that attempts to import a conflicting snapshot after the
wait list has been collected.

Without the accompanying fix, the import succeeds and the test fails because
the importer is absent from recovery's fixed wait list.  The second half also
checks that imports from the source become possible again after its old VXID
has been resolved.

Reported-by: Scott Ray <scott@scottray.io>
Discussion: https://postgr.es/m/QpAansP4iVg_ttSs9x81PFAptL2sqR3AS06u8Jksm3_bHJvUwQjHOocRajbxBc3iiLdf9ZMC6gtXjZsxdxvOmqp98hLZcZuWyBDuQxS6uZc=@scottray.io
---
 src/backend/storage/ipc/standby.c             |   6 +
 src/test/recovery/meson.build                 |   1 +
 .../t/057_snapshot_import_conflict.pl         | 116 ++++++++++++++++++
 3 files changed, 123 insertions(+)
 create mode 100644 src/test/recovery/t/057_snapshot_import_conflict.pl

diff --git a/src/backend/storage/ipc/standby.c b/src/backend/storage/ipc/standby.c
index 7f011e04990..7065840fc26 100644
--- a/src/backend/storage/ipc/standby.c
+++ b/src/backend/storage/ipc/standby.c
@@ -439,6 +439,11 @@ ResolveRecoveryConflictWithVirtualXIDs(VirtualTransactionId *waitlist,
 			}
 		}
 
+		if (reason == RECOVERY_CONFLICT_SNAPSHOT)
+		{
+			INJECTION_POINT("recovery-conflict-snapshot-resolved", NULL);
+		}
+
 		/* The virtual transaction is gone now, wait for the next one */
 		waitlist++;
 	}
@@ -491,6 +496,7 @@ ResolveRecoveryConflictWithSnapshot(TransactionId snapshotConflictHorizon,
 	Assert(TransactionIdIsNormal(snapshotConflictHorizon));
 	backends = GetConflictingVirtualXIDs(snapshotConflictHorizon,
 										 locator.dbOid);
+	INJECTION_POINT("recovery-conflict-snapshot-scan-complete", NULL);
 	ResolveRecoveryConflictWithVirtualXIDs(backends,
 										   RECOVERY_CONFLICT_SNAPSHOT,
 										   WAIT_EVENT_RECOVERY_CONFLICT_SNAPSHOT,
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 72113c5ac6e..4dca2b8acea 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -65,6 +65,7 @@ tests += {
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
       't/056_standby_snapshot_export.pl',
+      't/057_snapshot_import_conflict.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/057_snapshot_import_conflict.pl b/src/test/recovery/t/057_snapshot_import_conflict.pl
new file mode 100644
index 00000000000..bb1ae44525c
--- /dev/null
+++ b/src/test/recovery/t/057_snapshot_import_conflict.pl
@@ -0,0 +1,116 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Verify that standby recovery prevents snapshot imports from creating new
+# conflicts after it has collected the VXIDs that conflict with a cleanup WAL
+# record.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init(allows_streaming => 1);
+$primary->append_conf('postgresql.conf', 'autovacuum = off');
+$primary->start;
+
+if (!$primary->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$primary->safe_psql(
+	'postgres', q[
+CREATE EXTENSION injection_points;
+CREATE TABLE t AS SELECT generate_series(1, 100) AS id;
+]);
+
+$primary->backup('backup');
+my $standby = PostgreSQL::Test::Cluster->new('standby');
+$standby->init_from_backup($primary, 'backup', has_streaming => 1);
+$standby->append_conf('postgresql.conf',
+	'max_standby_streaming_delay = -1');
+$standby->start;
+
+# Register an old xmin and export its snapshot on the standby.
+my $exporter =
+  $standby->background_psql('postgres', on_error_stop => 0);
+$exporter->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ');
+is($exporter->query_safe('SELECT count(*) FROM t'), 100,
+	'exporter sees all rows');
+my $old_snapshot = $exporter->query_safe('SELECT pg_export_snapshot()');
+
+$standby->safe_psql(
+	'postgres', q[
+SELECT injection_points_attach('recovery-conflict-snapshot-scan-complete', 'wait');
+SELECT injection_points_attach('recovery-conflict-snapshot-resolved', 'wait');
+]);
+
+# Generate a cleanup record whose replay conflicts with the old snapshot.
+$primary->safe_psql(
+	'postgres', q[
+DELETE FROM t;
+VACUUM t;
+]);
+
+# Recovery has completed its conflict scan, but has not started waiting for
+# the exporter yet.
+$standby->wait_for_event('startup',
+	'recovery-conflict-snapshot-scan-complete');
+
+my ($stdout, $stderr);
+my $result = $standby->psql(
+	'postgres',
+	"BEGIN ISOLATION LEVEL REPEATABLE READ; "
+	  . "SET TRANSACTION SNAPSHOT '$old_snapshot';",
+	stdout => \$stdout,
+	stderr => \$stderr);
+isnt($result, 0, 'cannot import a snapshot from a tracked source');
+
+# Let recovery start waiting, then end the conflicting VXID.  Recovery pauses
+# after resolving it, before it can replay the cleanup record.
+$standby->safe_psql(
+	'postgres', q[
+SELECT injection_points_detach('recovery-conflict-snapshot-scan-complete');
+SELECT injection_points_wakeup('recovery-conflict-snapshot-scan-complete');
+]);
+$exporter->query_safe('COMMIT');
+$standby->wait_for_event('startup',
+	'recovery-conflict-snapshot-resolved');
+
+# Reuse the same backend for a new transaction.  Snapshot import must be
+# allowed again once startup has resolved the old VXID.
+$exporter->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ');
+my $new_snapshot = $exporter->query_safe('SELECT pg_export_snapshot()');
+$result = $standby->psql(
+	'postgres',
+	"BEGIN ISOLATION LEVEL REPEATABLE READ; "
+	  . "SET TRANSACTION SNAPSHOT '$new_snapshot'; SELECT count(*) FROM t;",
+	stdout => \$stdout,
+	stderr => \$stderr);
+is($result, 0, 'can import from the source after its tracked VXID ends');
+$stdout =~ s/^\s+|\s+$//g;
+is($stdout, '0', 'new snapshot sees the replayed delete');
+
+$standby->safe_psql(
+	'postgres', q[
+SELECT injection_points_detach('recovery-conflict-snapshot-resolved');
+SELECT injection_points_wakeup('recovery-conflict-snapshot-resolved');
+]);
+$exporter->query_safe('COMMIT');
+$exporter->quit;
+
+$primary->wait_for_replay_catchup($standby);
+is($standby->safe_psql('postgres', 'SELECT count(*) FROM t'), 0,
+	'standby replays the cleanup after the conflict ends');
+
+$standby->stop;
+$primary->stop;
+
+done_testing();
-- 
2.43.0

