From 5b4735b8b3c866f5739f162f38992c5a04b6abde Mon Sep 17 00:00:00 2001
From: Jeevan Chalke <jeevan.chalke@enterprisedb.com>
Date: Wed, 23 Sep 2026 14:59:18 +0530
Subject: [PATCH v2 2/2] Add regression test for shared-partial-path
 use-after-free

Add two tests to select_parallel.sql reproducing the crash fixed by
the previous commit, from independent reports:

- A UNION branch joining two 7+ partition relations with a
  set-returning function in the target list, under parallel query.
  At least 7 partitions per relation are needed to get enough
  competing partial-path candidates for the crash's dominance check
  to trigger. The crash happens during planning, so the plain SELECT
  alone already exercises it -- no EXPLAIN needed, keeping the
  expected output small and independent of plan shape, which could
  easily vary across backpatched versions.

- A single-table UNION arm with a set-returning function forcing
  current_rel and final_rel apart even without partitioning, where
  two competing partial index paths on tenk1 tie in cost once the
  SRF's cost swamps the difference between them.

Pins max_parallel_workers_per_gather locally, since an earlier
non-local SET in this file otherwise changes the ambient worker count
enough to avoid the conflict the first test needs.
---
 src/test/regress/expected/select_parallel.out | 88 +++++++++++++++++++
 src/test/regress/sql/select_parallel.sql      | 79 +++++++++++++++++
 2 files changed, 167 insertions(+)

diff --git a/src/test/regress/expected/select_parallel.out b/src/test/regress/expected/select_parallel.out
index e1344215644..979036de51a 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -1298,6 +1298,94 @@ SELECT unnest(ARRAY[]::integer[]) + 1 AS pathkey
                                  ->  Parallel Index Only Scan using tenk1_hundred on public.tenk1 t2
 (15 rows)
 
+-- Regression test for a use-after-free of a shared partial path.
+--
+-- Planning a UNION branch recurses into its own subquery_planner() call.
+-- If that branch's own scan/join rel builds a Gather/Gather Merge path
+-- over one of its partial paths, and grouping_planner() then promotes
+-- that same (still partial_pathlist-resident) partial path, unmodified,
+-- to the outer query level's final rel so an outer Gather could use it,
+-- a dominance comparison against another promoted candidate could decide
+-- to pfree() it -- even though the branch's own Gather/Gather Merge path
+-- still points to it.  This crashed with "unrecognized node type: N" once
+-- the freed memory got reused.  Needs at least 7 partitions per relation
+-- to get enough competing partial-path candidates for the promotion's own
+-- dominance check to discard one that a Gather still depends on.
+SAVEPOINT settings;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL max_parallel_workers_per_gather = 2;
+CREATE TABLE psrf_upload (
+    id int,
+    crt_time timestamp,
+    jdata jsonb,
+    tag text
+) PARTITION BY RANGE (crt_time);
+CREATE TABLE psrf_upload_dtl (
+    id int,
+    upload_id int,
+    crt_time timestamp,
+    jdata jsonb
+) PARTITION BY RANGE (crt_time);
+CREATE TABLE psrf_upload_p0 PARTITION OF psrf_upload FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
+CREATE TABLE psrf_upload_p1 PARTITION OF psrf_upload FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
+CREATE TABLE psrf_upload_p2 PARTITION OF psrf_upload FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
+CREATE TABLE psrf_upload_p3 PARTITION OF psrf_upload FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
+CREATE TABLE psrf_upload_p4 PARTITION OF psrf_upload FOR VALUES FROM ('2024-05-01') TO ('2024-06-01');
+CREATE TABLE psrf_upload_p5 PARTITION OF psrf_upload FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');
+CREATE TABLE psrf_upload_p6 PARTITION OF psrf_upload FOR VALUES FROM ('2024-07-01') TO ('2024-08-01');
+CREATE TABLE psrf_upload_dtl_p0 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
+CREATE TABLE psrf_upload_dtl_p1 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
+CREATE TABLE psrf_upload_dtl_p2 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
+CREATE TABLE psrf_upload_dtl_p3 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
+CREATE TABLE psrf_upload_dtl_p4 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-05-01') TO ('2024-06-01');
+CREATE TABLE psrf_upload_dtl_p5 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');
+CREATE TABLE psrf_upload_dtl_p6 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-07-01') TO ('2024-08-01');
+INSERT INTO psrf_upload VALUES
+  (1, '2024-05-01 00:00:00', jsonb_build_array(jsonb_build_object('v', repeat('x', 1000))), 'tag1');
+INSERT INTO psrf_upload_dtl
+  SELECT id, id, crt_time, jsonb_build_array(jsonb_build_object('v', repeat('x', 1000)))
+  FROM psrf_upload;
+ANALYZE psrf_upload;
+ANALYZE psrf_upload_dtl;
+SELECT id, tag, length(val) FROM (
+  SELECT u.id, u.tag, jsonb_array_elements(u.jdata)->>'v' AS val
+  FROM psrf_upload u
+  UNION
+  SELECT u.id, u.tag, jsonb_array_elements(d.jdata)->>'v' AS val
+  FROM psrf_upload u LEFT JOIN psrf_upload_dtl d ON d.upload_id = u.id
+) s;
+ id | tag  | length 
+----+------+--------
+  1 | tag1 |   1000
+(1 row)
+
+-- Same underlying issue, triggered a different way: a set-returning
+-- function in the target list forces current_rel and final_rel apart even
+-- for this single-relation query, and two competing partial index paths
+-- tie in cost once the SRF's cost swamps the difference between them.
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL max_parallel_workers_per_gather = 1;
+EXPLAIN (COSTS OFF)
+SELECT unique1, generate_series(1, 5000000) FROM tenk1 WHERE unique2 < 100
+UNION
+SELECT 1, 2;
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
+ Unique
+   ->  Sort
+         Sort Key: tenk1.unique1, (generate_series(1, 5000000))
+         ->  Append
+               ->  Gather
+                     Workers Planned: 1
+                     ->  ProjectSet
+                           ->  Parallel Index Scan using tenk1_unique2 on tenk1
+                                 Index Cond: (unique2 < 100)
+               ->  Result
+(10 rows)
+
+ROLLBACK TO SAVEPOINT settings;
 -- test passing expanded-value representations to workers
 CREATE FUNCTION make_some_array(int,int) returns int[] as
 $$declare x int[];
diff --git a/src/test/regress/sql/select_parallel.sql b/src/test/regress/sql/select_parallel.sql
index 71a75bc86ea..8a6b5302896 100644
--- a/src/test/regress/sql/select_parallel.sql
+++ b/src/test/regress/sql/select_parallel.sql
@@ -495,6 +495,85 @@ SELECT unnest(ARRAY[]::integer[]) + 1 AS pathkey
   FROM tenk1 t1 JOIN tenk1 t2 ON TRUE
   ORDER BY pathkey;
 
+-- Regression test for a use-after-free of a shared partial path.
+--
+-- Planning a UNION branch recurses into its own subquery_planner() call.
+-- If that branch's own scan/join rel builds a Gather/Gather Merge path
+-- over one of its partial paths, and grouping_planner() then promotes
+-- that same (still partial_pathlist-resident) partial path, unmodified,
+-- to the outer query level's final rel so an outer Gather could use it,
+-- a dominance comparison against another promoted candidate could decide
+-- to pfree() it -- even though the branch's own Gather/Gather Merge path
+-- still points to it.  This crashed with "unrecognized node type: N" once
+-- the freed memory got reused.  Needs at least 7 partitions per relation
+-- to get enough competing partial-path candidates for the promotion's own
+-- dominance check to discard one that a Gather still depends on.
+SAVEPOINT settings;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL max_parallel_workers_per_gather = 2;
+
+CREATE TABLE psrf_upload (
+    id int,
+    crt_time timestamp,
+    jdata jsonb,
+    tag text
+) PARTITION BY RANGE (crt_time);
+CREATE TABLE psrf_upload_dtl (
+    id int,
+    upload_id int,
+    crt_time timestamp,
+    jdata jsonb
+) PARTITION BY RANGE (crt_time);
+
+CREATE TABLE psrf_upload_p0 PARTITION OF psrf_upload FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
+CREATE TABLE psrf_upload_p1 PARTITION OF psrf_upload FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
+CREATE TABLE psrf_upload_p2 PARTITION OF psrf_upload FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
+CREATE TABLE psrf_upload_p3 PARTITION OF psrf_upload FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
+CREATE TABLE psrf_upload_p4 PARTITION OF psrf_upload FOR VALUES FROM ('2024-05-01') TO ('2024-06-01');
+CREATE TABLE psrf_upload_p5 PARTITION OF psrf_upload FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');
+CREATE TABLE psrf_upload_p6 PARTITION OF psrf_upload FOR VALUES FROM ('2024-07-01') TO ('2024-08-01');
+
+CREATE TABLE psrf_upload_dtl_p0 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
+CREATE TABLE psrf_upload_dtl_p1 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
+CREATE TABLE psrf_upload_dtl_p2 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
+CREATE TABLE psrf_upload_dtl_p3 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
+CREATE TABLE psrf_upload_dtl_p4 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-05-01') TO ('2024-06-01');
+CREATE TABLE psrf_upload_dtl_p5 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');
+CREATE TABLE psrf_upload_dtl_p6 PARTITION OF psrf_upload_dtl FOR VALUES FROM ('2024-07-01') TO ('2024-08-01');
+
+INSERT INTO psrf_upload VALUES
+  (1, '2024-05-01 00:00:00', jsonb_build_array(jsonb_build_object('v', repeat('x', 1000))), 'tag1');
+INSERT INTO psrf_upload_dtl
+  SELECT id, id, crt_time, jsonb_build_array(jsonb_build_object('v', repeat('x', 1000)))
+  FROM psrf_upload;
+
+ANALYZE psrf_upload;
+ANALYZE psrf_upload_dtl;
+
+SELECT id, tag, length(val) FROM (
+  SELECT u.id, u.tag, jsonb_array_elements(u.jdata)->>'v' AS val
+  FROM psrf_upload u
+  UNION
+  SELECT u.id, u.tag, jsonb_array_elements(d.jdata)->>'v' AS val
+  FROM psrf_upload u LEFT JOIN psrf_upload_dtl d ON d.upload_id = u.id
+) s;
+
+-- Same underlying issue, triggered a different way: a set-returning
+-- function in the target list forces current_rel and final_rel apart even
+-- for this single-relation query, and two competing partial index paths
+-- tie in cost once the SRF's cost swamps the difference between them.
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL max_parallel_workers_per_gather = 1;
+
+EXPLAIN (COSTS OFF)
+SELECT unique1, generate_series(1, 5000000) FROM tenk1 WHERE unique2 < 100
+UNION
+SELECT 1, 2;
+
+ROLLBACK TO SAVEPOINT settings;
+
 -- test passing expanded-value representations to workers
 CREATE FUNCTION make_some_array(int,int) returns int[] as
 $$declare x int[];
-- 
2.43.0

