From 9557e372fcf7822afa4ef64dec498ceaeaefefb8 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Tue, 14 Apr 2026 14:29:14 +0500
Subject: [PATCH v2 3/5] Add TAP test for plpgsql RETURN QUERY DDL race

Add an injection-point TAP reproducer for concurrent ALTER TYPE +
CREATE OR REPLACE FUNCTION during an in-progress RETURN QUERY call.

The test captures the mid-statement rowshape mismatch scenario.
---
 src/pl/plpgsql/src/pl_exec.c                  |  6 ++
 src/test/modules/test_misc/meson.build        |  1 +
 .../t/012_plpgsql_composite_replan_race.pl    | 96 +++++++++++++++++++
 3 files changed, 103 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/012_plpgsql_composite_replan_race.pl

diff --git a/src/pl/plpgsql/src/pl_exec.c b/src/pl/plpgsql/src/pl_exec.c
index 65b0fd0790f..844f8080dc0 100644
--- a/src/pl/plpgsql/src/pl_exec.c
+++ b/src/pl/plpgsql/src/pl_exec.c
@@ -41,6 +41,7 @@
 #include "utils/builtins.h"
 #include "utils/datum.h"
 #include "utils/fmgroids.h"
+#include "utils/injection_point.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
@@ -3594,6 +3595,11 @@ exec_stmt_return_query(PLpgSQL_execstate *estate,
 	/* There might be some tuples in the tuplestore already */
 	tcount = tuplestore_tuple_count(estate->tuple_store);
 
+	/*
+	 * Test-only pause point for RETURN QUERY race conditions.
+	 */
+	INJECTION_POINT("plpgsql-return-query-before-exec", NULL);
+
 	/*
 	 * Set up DestReceiver to transfer results directly to tuplestore,
 	 * converting rowtype if necessary.  DestReceiver lives in mcontext.
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index ee290698b31..38661fa4bee 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -23,6 +23,7 @@ tests += {
       't/012_ddlutils.pl',
       't/013_temp_obj_multisession.pl',
       't/014_log_statement_max_length.pl',
+      't/012_plpgsql_composite_replan_race.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/012_plpgsql_composite_replan_race.pl b/src/test/modules/test_misc/t/012_plpgsql_composite_replan_race.pl
new file mode 100644
index 00000000000..e151409457d
--- /dev/null
+++ b/src/test/modules/test_misc/t/012_plpgsql_composite_replan_race.pl
@@ -0,0 +1,96 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+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 $node = PostgreSQL::Test::Cluster->new('plpgsql_composite_replan_race');
+$node->init;
+$node->start;
+
+if (!$node->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+$node->safe_psql('postgres', q[
+CREATE TYPE planinv_ct AS (a int, b int);
+CREATE TABLE planinv_tbl (a int, b int);
+INSERT INTO planinv_tbl VALUES (1, 2);
+CREATE FUNCTION planinv_srf() RETURNS SETOF planinv_ct
+  LANGUAGE sql STABLE SECURITY DEFINER AS $$
+    SELECT a, b FROM planinv_tbl
+  $$;
+CREATE FUNCTION planinv_caller() RETURNS SETOF planinv_ct LANGUAGE plpgsql AS $$
+BEGIN
+  RETURN QUERY SELECT r.* FROM planinv_srf() r;
+END;
+$$;
+]);
+
+# Warm up expression/plan caches first.
+is($node->safe_psql('postgres', 'SELECT * FROM planinv_caller();'), '1|2',
+	'warmup call returns initial row shape');
+
+my $backend2 = $node->background_psql('postgres', on_error_stop => 0);
+$backend2->query_safe(q[
+SELECT injection_points_set_local();
+SELECT injection_points_attach('plpgsql-return-query-before-exec', 'wait');
+]);
+
+$backend2->query_until(
+	qr/race_started/, q[
+\echo race_started
+BEGIN;
+SELECT * FROM planinv_caller();
+\echo race_done
+]);
+
+$node->poll_query_until('postgres', q[
+SELECT EXISTS (
+  SELECT 1
+  FROM pg_stat_activity
+  WHERE wait_event_type = 'InjectionPoint'
+    AND wait_event = 'plpgsql-return-query-before-exec'
+);
+]) or die 'backend2 did not reach injection point in time';
+
+$node->safe_psql('postgres', q[
+BEGIN;
+ALTER TYPE planinv_ct ADD ATTRIBUTE c int;
+CREATE OR REPLACE FUNCTION planinv_srf() RETURNS SETOF planinv_ct
+  LANGUAGE sql STABLE SECURITY DEFINER AS $$
+    SELECT a, b, 99 FROM planinv_tbl
+  $$;
+COMMIT;
+]);
+
+$node->safe_psql('postgres',
+	"SELECT injection_points_wakeup('plpgsql-return-query-before-exec');");
+
+my $out = $backend2->query_until(qr/race_done/, q[]);
+like($out, qr/^1\|2\|99$/m,
+	'concurrent ALTER TYPE + CREATE OR REPLACE does not break RETURN QUERY');
+is($backend2->{stderr}, '',
+	'no tuple shape mismatch reported by RETURN QUERY');
+
+ok($backend2->quit);
+
+$node->safe_psql('postgres', q[
+DROP FUNCTION planinv_caller();
+DROP FUNCTION planinv_srf();
+DROP TABLE planinv_tbl;
+DROP TYPE planinv_ct;
+]);
+
+done_testing();
-- 
2.50.1 (Apple Git-155)

