From 8a8914b9c489e8b7b72373c8fcea6e4d5b05f5a5 Mon Sep 17 00:00:00 2001
From: Robert Haas <rhaas@postgresql.org>
Date: Thu, 26 Mar 2026 11:53:49 -0400
Subject: [PATCH v23 2/4] pg_plan_advice: Invent
 DO_NOT_SCAN(relation_identifier).

The premise of src/test/modules/test_plan_advice is that if we plan
a query once, generate plan advice, and then replan it using that
same advice, all of that advice should apply cleanly, since the
settings and everything else are the same. Unfortunately, that's
not the case: the test suite is the main regression tests, and
concurrent activity can change the statistics on tables involved
in the query, especially system catalogs. That's OK as long as it
only affects costing, but in a few cases, it affects which relations
appear in the final plan at all.

In the buildfarm failures observed to date, this happens because
we consider alternative subplans for the same portion of the query;
in theory, MinMaxAggPath is vulnerable to a similar hazard. In both
cases, the planner clones an entire subquery, and the clone has a
different plan name, and therefore different range table identifiers,
than the original. If a cost change results in flipping between one
of these plans and the other, the test_plan_advice tests will fail,
because the range table identifiers to which advice was applied won't
even be present in the output of the second planning cycle.

To fix, invent a new DO_NOT_SCAN advice tag. When generating advice,
emit it for relations that should not appear in the final plan at
all, because some alternative version of that relation was used
instead. When DO_NOT_SCAN is supplied, disable all scan methods for
that relation.

To make this work, we reuse a bunch of the machinery that previously
existed for the purpose of ensuring that we build the same set of
relation identifiers during planning as we do from the final
PlannedStmt. In the process, this commit slightly weakens the
cross-check mechanism: before this commit, it would fire whenever
the pg_plan_advice module was loaded, even if pg_plan_advice wasn't
actually doing anything; now, it will only engage when we have some
other reason to create a pgpa_planner_state. The old way was complex
and didn't add much useful test coverage, so this seems like an
acceptable sacrifice.
---
 contrib/pg_plan_advice/README                 |   7 +
 .../pg_plan_advice/expected/alternatives.out  | 158 +++++++++++++
 contrib/pg_plan_advice/expected/scan.out      |  17 +-
 contrib/pg_plan_advice/meson.build            |   1 +
 contrib/pg_plan_advice/pgpa_ast.c             |   6 +
 contrib/pg_plan_advice/pgpa_ast.h             |   1 +
 contrib/pg_plan_advice/pgpa_output.c          |  35 +++
 contrib/pg_plan_advice/pgpa_planner.c         | 213 ++++++++++--------
 contrib/pg_plan_advice/pgpa_planner.h         |  26 ++-
 contrib/pg_plan_advice/pgpa_trove.c           |   1 +
 contrib/pg_plan_advice/pgpa_walker.c          | 156 +++++++++++--
 contrib/pg_plan_advice/pgpa_walker.h          |   1 +
 contrib/pg_plan_advice/sql/alternatives.sql   |  58 +++++
 contrib/pg_plan_advice/sql/scan.sql           |   5 +-
 doc/src/sgml/pgplanadvice.sgml                |  14 +-
 15 files changed, 581 insertions(+), 118 deletions(-)
 create mode 100644 contrib/pg_plan_advice/expected/alternatives.out
 create mode 100644 contrib/pg_plan_advice/sql/alternatives.sql

diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
index b0e4fd1d6e1..2ea61e9bc41 100644
--- a/contrib/pg_plan_advice/README
+++ b/contrib/pg_plan_advice/README
@@ -109,6 +109,13 @@ Bitmap heap scans currently do not allow for an index specification:
 BITMAP_HEAP_SCAN(foo bar) simply means that each of foo and bar should use
 some sort of bitmap heap scan.
 
+There is a special DO_NOT_SCAN() advice tag which says that a certain
+relation shouldn't be scanned at all. This is used to control which of
+two choices is selected when an AlternativeSubPlan is resolved, and
+whether or not a MinMaxAggPath is chosen. Control over upper planner
+behavior is generally out-of-scope at the moment, but these cases had
+to be handled to prevent test_plan_advice failures in the buildfarm.
+
 Join Order Advice
 =================
 
diff --git a/contrib/pg_plan_advice/expected/alternatives.out b/contrib/pg_plan_advice/expected/alternatives.out
new file mode 100644
index 00000000000..a6fb296d4b4
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/alternatives.out
@@ -0,0 +1,158 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE alt_t1 (a int) WITH (autovacuum_enabled = false);
+CREATE TABLE alt_t2 (a int) WITH (autovacuum_enabled = false);
+CREATE INDEX ON alt_t2(a);
+INSERT INTO alt_t1 SELECT generate_series(1, 1000);
+INSERT INTO alt_t2 SELECT generate_series(1, 100000);
+VACUUM ANALYZE alt_t1;
+VACUUM ANALYZE alt_t2;
+-- This query uses an OR to prevent the EXISTS from being converted to a
+-- semi-join, forcing the planner through the AlternativeSubPlan path.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM alt_t1
+WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Seq Scan on alt_t1
+   Filter: ((ANY (a = (hashed SubPlan exists_2).col1)) OR (a < 0))
+   SubPlan exists_2
+     ->  Seq Scan on alt_t2
+ Generated Plan Advice:
+   SEQ_SCAN(alt_t1 alt_t2@exists_2)
+   NO_GATHER(alt_t1 alt_t2@exists_2)
+   DO_NOT_SCAN(alt_t2@exists_1)
+(8 rows)
+
+-- We should be able to force either AlternativeSubPlan by advising against
+-- scanning the other relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM alt_t1
+WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Seq Scan on alt_t1
+   Filter: ((ANY (a = (hashed SubPlan exists_2).col1)) OR (a < 0))
+   SubPlan exists_2
+     ->  Seq Scan on alt_t2
+ Supplied Plan Advice:
+   DO_NOT_SCAN(alt_t2@exists_1) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(alt_t1 alt_t2@exists_2)
+   NO_GATHER(alt_t1 alt_t2@exists_2)
+   DO_NOT_SCAN(alt_t2@exists_1)
+(10 rows)
+
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM alt_t1
+WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Seq Scan on alt_t1
+   Filter: (EXISTS(SubPlan exists_1) OR (a < 0))
+   SubPlan exists_1
+     ->  Index Only Scan using alt_t2_a_idx on alt_t2
+           Index Cond: (a = alt_t1.a)
+ Supplied Plan Advice:
+   DO_NOT_SCAN(alt_t2@exists_2) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(alt_t1)
+   INDEX_ONLY_SCAN(alt_t2@exists_1 public.alt_t2_a_idx)
+   NO_GATHER(alt_t1 alt_t2@exists_1)
+   DO_NOT_SCAN(alt_t2@exists_2)
+(12 rows)
+
+COMMIT;
+-- Now let's test a case involving MinMaxAggPath, which we treat similarly
+-- to the AlternativeSubPlan case.
+CREATE TABLE alt_minmax (a int) WITH (autovacuum_enabled = false);
+CREATE INDEX ON alt_minmax(a);
+INSERT INTO alt_minmax SELECT generate_series(1, 10000);
+VACUUM ANALYZE alt_minmax;
+-- Using an Index Scan inside of an InitPlan should win over a full table
+-- scan.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
+ Result
+   Replaces: MinMaxAggregate
+   InitPlan minmax_1
+     ->  Limit
+           ->  Index Only Scan using alt_minmax_a_idx on alt_minmax
+                 Index Cond: (a IS NOT NULL)
+   InitPlan minmax_2
+     ->  Limit
+           ->  Index Only Scan Backward using alt_minmax_a_idx on alt_minmax alt_minmax_1
+                 Index Cond: (a IS NOT NULL)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(alt_minmax@minmax_1 public.alt_minmax_a_idx
+    alt_minmax@minmax_2 public.alt_minmax_a_idx)
+   NO_GATHER(alt_minmax@minmax_1 alt_minmax@minmax_2)
+   DO_NOT_SCAN(alt_minmax)
+(15 rows)
+
+-- Advising against the scan of alt_minmax at the root query level should
+-- change nothing, but if we say we don't want either of or both of the
+-- minmax-variant scans, the plan should switch to a full table scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
+ Result
+   Replaces: MinMaxAggregate
+   InitPlan minmax_1
+     ->  Limit
+           ->  Index Only Scan using alt_minmax_a_idx on alt_minmax
+                 Index Cond: (a IS NOT NULL)
+   InitPlan minmax_2
+     ->  Limit
+           ->  Index Only Scan Backward using alt_minmax_a_idx on alt_minmax alt_minmax_1
+                 Index Cond: (a IS NOT NULL)
+ Supplied Plan Advice:
+   DO_NOT_SCAN(alt_minmax) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(alt_minmax@minmax_1 public.alt_minmax_a_idx
+    alt_minmax@minmax_2 public.alt_minmax_a_idx)
+   NO_GATHER(alt_minmax@minmax_1 alt_minmax@minmax_2)
+   DO_NOT_SCAN(alt_minmax)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Aggregate
+   ->  Seq Scan on alt_minmax
+ Supplied Plan Advice:
+   DO_NOT_SCAN(alt_minmax@minmax_1) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(alt_minmax)
+   NO_GATHER(alt_minmax)
+   DO_NOT_SCAN(alt_minmax@minmax_1 alt_minmax@minmax_2)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1) DO_NOT_SCAN(alt_minmax@minmax_2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Aggregate
+   ->  Seq Scan on alt_minmax
+ Supplied Plan Advice:
+   DO_NOT_SCAN(alt_minmax@minmax_1) /* matched */
+   DO_NOT_SCAN(alt_minmax@minmax_2) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(alt_minmax)
+   NO_GATHER(alt_minmax)
+   DO_NOT_SCAN(alt_minmax@minmax_1 alt_minmax@minmax_2)
+(9 rows)
+
+COMMIT;
+DROP TABLE alt_t1, alt_t2, alt_minmax;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
index 3f9e13b6d41..44ce40f33a6 100644
--- a/contrib/pg_plan_advice/expected/scan.out
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -270,7 +270,8 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
 COMMIT;
 -- We can force a primary key lookup to use a sequential scan, but we
 -- can't force it to use an index-only scan (due to the column list)
--- or a TID scan (due to the absence of a TID qual).
+-- or a TID scan (due to the absence of a TID qual). If we apply DO_NOT_SCAN
+-- here, we should get a valid plan anyway, but with the scan disabled.
 BEGIN;
 SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
@@ -313,6 +314,20 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
    NO_GATHER(scan_table)
 (8 rows)
 
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   DO_NOT_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
 COMMIT;
 -- We can forcibly downgrade an index-only scan to an index scan, but we can't
 -- force the use of an index that the planner thinks is inapplicable.
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
index 36bbc4e9826..f2098947b64 100644
--- a/contrib/pg_plan_advice/meson.build
+++ b/contrib/pg_plan_advice/meson.build
@@ -53,6 +53,7 @@ tests += {
   'bd': meson.current_build_dir(),
   'regress': {
     'sql': [
+      'alternatives',
       'gather',
       'join_order',
       'join_strategy',
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
index f4fa6a626d4..3c340c6ae7a 100644
--- a/contrib/pg_plan_advice/pgpa_ast.c
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -32,6 +32,8 @@ pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
 	{
 		case PGPA_TAG_BITMAP_HEAP_SCAN:
 			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_DO_NOT_SCAN:
+			return "DO_NOT_SCAN";
 		case PGPA_TAG_FOREIGN_JOIN:
 			return "FOREIGN_JOIN";
 		case PGPA_TAG_GATHER:
@@ -92,6 +94,10 @@ pgpa_parse_advice_tag(const char *tag, bool *fail)
 			if (strcmp(tag, "bitmap_heap_scan") == 0)
 				return PGPA_TAG_BITMAP_HEAP_SCAN;
 			break;
+		case 'd':
+			if (strcmp(tag, "do_not_scan") == 0)
+				return PGPA_TAG_DO_NOT_SCAN;
+			break;
 		case 'f':
 			if (strcmp(tag, "foreign_join") == 0)
 				return PGPA_TAG_FOREIGN_JOIN;
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
index 3c3db801926..a89f1251929 100644
--- a/contrib/pg_plan_advice/pgpa_ast.h
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -80,6 +80,7 @@ typedef struct pgpa_advice_target
 typedef enum pgpa_advice_tag_type
 {
 	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_DO_NOT_SCAN,
 	PGPA_TAG_FOREIGN_JOIN,
 	PGPA_TAG_GATHER,
 	PGPA_TAG_GATHER_MERGE,
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
index 28d2839ce1a..cd4411f350c 100644
--- a/contrib/pg_plan_advice/pgpa_output.c
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -54,6 +54,8 @@ static void pgpa_output_simple_strategy(pgpa_output_context *context,
 										List *relid_sets);
 static void pgpa_output_no_gather(pgpa_output_context *context,
 								  Bitmapset *relids);
+static void pgpa_output_do_not_scan(pgpa_output_context *context,
+									List *identifiers);
 static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
 								  Bitmapset *relids);
 
@@ -156,6 +158,9 @@ pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
 
 	/* Emit NO_GATHER advice. */
 	pgpa_output_no_gather(&context, walker->no_gather_scans);
+
+	/* Emit DO_NOT_SCAN advice. */
+	pgpa_output_do_not_scan(&context, walker->do_not_scan_identifiers);
 }
 
 /*
@@ -395,6 +400,36 @@ pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
 	appendStringInfoChar(context->buf, ')');
 }
 
+/*
+ * Output DO_NOT_SCAN advice for all relations in the provided list of
+ * identifiers.
+ */
+static void
+pgpa_output_do_not_scan(pgpa_output_context *context, List *identifiers)
+{
+	bool		first = true;
+
+	if (identifiers == NIL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "DO_NOT_SCAN(");
+
+	foreach_ptr(pgpa_identifier, rid, identifiers)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		appendStringInfoString(context->buf, pgpa_identifier_string(rid));
+	}
+
+	appendStringInfoChar(context->buf, ')');
+}
+
 /*
  * Output the identifiers for each RTI in the provided set.
  *
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index 6b574da655f..afa9587a725 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -164,11 +164,13 @@ static void pgpa_planner_feedback_warning(List *feedback);
 static pgpa_planner_info *pgpa_planner_get_proot(pgpa_planner_state *pps,
 												 PlannerInfo *root);
 
-static inline void pgpa_ri_checker_save(pgpa_planner_state *pps,
-										PlannerInfo *root,
-										RelOptInfo *rel);
-static void pgpa_ri_checker_validate(pgpa_planner_state *pps,
-									 PlannedStmt *pstmt);
+static inline void pgpa_compute_rt_identifier(pgpa_planner_info *proot,
+											  PlannerInfo *root,
+											  RelOptInfo *rel);
+static void pgpa_compute_rt_offsets(pgpa_planner_state *pps,
+									PlannedStmt *pstmt);
+static void pgpa_validate_rt_identifiers(pgpa_planner_state *pps,
+										 PlannedStmt *pstmt);
 
 static char *pgpa_bms_to_cstring(Bitmapset *bms);
 static const char *pgpa_jointype_to_cstring(JoinType jointype);
@@ -264,20 +266,10 @@ pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
 		}
 	}
 
-#ifdef USE_ASSERT_CHECKING
-
-	/*
-	 * If asserts are enabled, always build a private state object for
-	 * cross-checks.
-	 */
-	needs_pps = true;
-#endif
-
 	/*
 	 * We only create and initialize a private state object if it's needed for
 	 * some purpose. That could be (1) recording that we will need to generate
-	 * an advice string, (2) storing a trove of supplied advice, or (3)
-	 * facilitating debugging cross-checks when asserts are enabled.
+	 * an advice string or (2) storing a trove of supplied advice.
 	 *
 	 * Currently, the active memory context should be one that will last for
 	 * the entire duration of query planning, but if GEQO is in use, it's
@@ -321,9 +313,16 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
 	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
 	if (pps != NULL)
 	{
+		/* Set up some local variables. */
 		trove = pps->trove;
 		generate_advice_feedback = pps->generate_advice_feedback;
 		generate_advice_string = pps->generate_advice_string;
+
+		/* Compute range table offsets. */
+		pgpa_compute_rt_offsets(pps, pstmt);
+
+		/* Cross-check range table identifiers. */
+		pgpa_validate_rt_identifiers(pps, pstmt);
 	}
 
 	/*
@@ -394,13 +393,6 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
 			lappend(pstmt->extension_state,
 					makeDefElem("pg_plan_advice", (Node *) pgpa_items, -1));
 
-	/*
-	 * If assertions are enabled, cross-check the generated range table
-	 * identifiers.
-	 */
-	if (pps != NULL)
-		pgpa_ri_checker_validate(pps, pstmt);
-
 	/* Pass call to previous hook. */
 	if (prev_planner_shutdown)
 		(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
@@ -408,35 +400,38 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
 
 /*
  * Hook function for build_simple_rel().
- *
- * We can apply scan advice at this point, and we also use this as an
- * opportunity to do range-table identifier cross-checking in assert-enabled
- * builds.
  */
 static void
 pgpa_build_simple_rel(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *rte)
 {
 	pgpa_planner_state *pps;
+	pgpa_planner_info *proot = NULL;
 
 	/* Fetch our private state, set up by pgpa_planner_setup(). */
 	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
 
-	/* Save details needed for range table identifier cross-checking. */
+	/*
+	 * Look up the pgpa_planner_info for this subquery, and make sure we've
+	 * saved a range table identifier.
+	 */
 	if (pps != NULL)
-		pgpa_ri_checker_save(pps, root, rel);
+	{
+		proot = pgpa_planner_get_proot(pps, root);
+		pgpa_compute_rt_identifier(proot, root, rel);
+	}
 
 	/* If query advice was provided, search for relevant entries. */
 	if (pps != NULL && pps->trove != NULL)
 	{
-		pgpa_identifier rid;
+		pgpa_identifier *rid;
 		pgpa_trove_result tresult_scan;
 		pgpa_trove_result tresult_rel;
 
 		/* Search for scan advice and general rel advice. */
-		pgpa_compute_identifier_by_rti(root, rel->relid, &rid);
-		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_SCAN, 1, &rid,
+		rid = &proot->rid_array[rel->relid - 1];
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_SCAN, 1, rid,
 						  &tresult_scan);
-		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, rid,
 						  &tresult_rel);
 
 		/* If relevant entries were found, apply them. */
@@ -1626,6 +1621,8 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 							   pgpa_trove_entry *rel_entries,
 							   Bitmapset *rel_indexes)
 {
+	const uint64 all_scan_mask = PGS_SCAN_ANY | PGS_APPEND |
+		PGS_MERGE_APPEND | PGS_CONSIDER_INDEXONLY;
 	bool		gather_conflict = false;
 	Bitmapset  *gather_partial_match = NULL;
 	Bitmapset  *gather_full_match = NULL;
@@ -1636,16 +1633,18 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 	Bitmapset  *scan_type_indexes = NULL;
 	Bitmapset  *scan_type_rel_indexes = NULL;
 	uint64		gather_mask = 0;
-	uint64		scan_type = 0;
+	uint64		scan_type = all_scan_mask;	/* sentinel: no advice yet */
 
 	/* Scrutinize available scan advice. */
 	while ((i = bms_next_member(scan_indexes, i)) >= 0)
 	{
 		pgpa_trove_entry *my_entry = &scan_entries[i];
-		uint64		my_scan_type = 0;
+		uint64		my_scan_type = all_scan_mask;
 
 		/* Translate our advice tags to a scan strategy advice value. */
-		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+		if (my_entry->tag == PGPA_TAG_DO_NOT_SCAN)
+			my_scan_type = 0;
+		else if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
 		{
 			/*
 			 * Currently, PGS_CONSIDER_INDEXONLY can suppress Bitmap Heap
@@ -1679,9 +1678,9 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 		 * INDEX_SCAN(a b.c) as non-conflicting if it happens that the only
 		 * index named c is in schema b, but it doesn't seem worth the code.
 		 */
-		if (my_scan_type != 0)
+		if (my_scan_type != all_scan_mask)
 		{
-			if (scan_type != 0 && scan_type != my_scan_type)
+			if (scan_type != all_scan_mask && scan_type != my_scan_type)
 				scan_type_conflict = true;
 			if (!scan_type_conflict && scan_entry != NULL &&
 				my_entry->target->itarget != NULL &&
@@ -1716,7 +1715,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 			{
 				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
 
-				if (scan_type != 0 && scan_type != my_scan_type)
+				if (scan_type != all_scan_mask && scan_type != my_scan_type)
 					scan_type_conflict = true;
 				scan_entry = my_entry;
 				scan_type = my_scan_type;
@@ -1795,7 +1794,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 		if (matched_index == NULL)
 		{
 			/* Don't force the scan type if the index doesn't exist. */
-			scan_type = 0;
+			scan_type = all_scan_mask;
 
 			/* Mark advice as inapplicable. */
 			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
@@ -1839,14 +1838,8 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 	 * Only clear bits here, so that we still respect the enable_* GUCs. Do
 	 * nothing in cases where the advice on a single topic conflicts.
 	 */
-	if (scan_type != 0 && !scan_type_conflict)
-	{
-		uint64		all_scan_mask;
-
-		all_scan_mask = PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
-			PGS_CONSIDER_INDEXONLY;
+	if (scan_type != all_scan_mask && !scan_type_conflict)
 		rel->pgs_mask &= ~(all_scan_mask & ~scan_type);
-	}
 	if (gather_mask != 0 && !gather_conflict)
 	{
 		uint64		all_gather_mask;
@@ -2001,9 +1994,38 @@ pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
 		}
 	}
 
-	/* Create new object, add to list, and make it most recently used. */
+	/* Create new object. */
 	new_proot = palloc0_object(pgpa_planner_info);
+
+	/* Set plan name and alternative plan name. */
 	new_proot->plan_name = root->plan_name;
+	new_proot->alternative_plan_name = root->alternative_plan_name;
+
+	/*
+	 * If the newly-created proot shares an alternative_plan_name with one or
+	 * more others, all should have the is_alternative_plan flag set.
+	 */
+	foreach_ptr(pgpa_planner_info, other_proot, pps->proots)
+	{
+		if (strings_equal_or_both_null(new_proot->alternative_plan_name,
+									   other_proot->alternative_plan_name))
+		{
+			new_proot->is_alternative_plan = true;
+			other_proot->is_alternative_plan = true;
+		}
+	}
+
+	/*
+	 * Outermost query level always has rtoffset 0; other rtoffset values are
+	 * computed later.
+	 */
+	if (root->plan_name == NULL)
+	{
+		new_proot->has_rtoffset = true;
+		new_proot->rtoffset = 0;
+	}
+
+	/* Add to list and make it most recently used. */
 	pps->proots = lappend(pps->proots, new_proot);
 	pps->last_proot = new_proot;
 
@@ -2011,19 +2033,15 @@ pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
 }
 
 /*
- * Save the range table identifier for one relation for future cross-checking.
+ * Compute the range table identifier for one relation and save it for future
+ * use.
  */
 static void
-pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
-					 RelOptInfo *rel)
+pgpa_compute_rt_identifier(pgpa_planner_info *proot, PlannerInfo *root,
+						   RelOptInfo *rel)
 {
-#ifdef USE_ASSERT_CHECKING
-	pgpa_planner_info *proot;
 	pgpa_identifier *rid;
 
-	/* Get the pgpa_planner_info for this PlannerInfo. */
-	proot = pgpa_planner_get_proot(pps, root);
-
 	/* Allocate or extend the proot's rid_array as necessary. */
 	if (proot->rid_array_size < rel->relid)
 	{
@@ -2043,36 +2061,32 @@ pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
 	rid = &proot->rid_array[rel->relid - 1];
 	if (rid->alias_name == NULL)
 		pgpa_compute_identifier_by_rti(root, rel->relid, rid);
-#endif
 }
 
 /*
- * Validate that the range table identifiers we were able to generate during
- * planning match the ones we generated from the final plan.
+ * Compute the range table offset for each pgpa_planner_info for which it
+ * is possible to meaningfully do so.
  */
 static void
-pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+pgpa_compute_rt_offsets(pgpa_planner_state *pps, PlannedStmt *pstmt)
 {
-#ifdef USE_ASSERT_CHECKING
-	pgpa_identifier *rt_identifiers;
-	Index		rtable_length = list_length(pstmt->rtable);
-
-	/* Create identifiers from the planned statement. */
-	rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
-
-	/* Iterate over identifiers created during planning, so we can compare. */
 	foreach_ptr(pgpa_planner_info, proot, pps->proots)
 	{
-		int			rtoffset = 0;
+		/* For the top query level, we've previously set rtoffset 0. */
+		if (proot->plan_name == NULL)
+		{
+			Assert(proot->has_rtoffset);
+			continue;
+		}
 
 		/*
-		 * If there's no plan name associated with this entry, then the
-		 * rtoffset is 0. Otherwise, we can search the SubPlanRTInfo list to
-		 * find the rtoffset.
+		 * It's not guaranteed that every plan name we saw during planning has
+		 * a SubPlanInfo, but any that do not certainly don't appear in the
+		 * final range table.
 		 */
-		if (proot->plan_name != NULL)
+		foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
 		{
-			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			if (strcmp(proot->plan_name, rtinfo->plan_name) == 0)
 			{
 				/*
 				 * If rtinfo->dummy is set, then the subquery's range table
@@ -2081,30 +2095,51 @@ pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
 				 * RTE_SUBQUERY entries that were once RTE_RELATION entries
 				 * will be copied, as per add_rtes_to_flat_rtable. Therefore,
 				 * there's no fixed rtoffset that we can apply to the RTIs
-				 * used during planning to locate the corresponding relations
+				 * used during planning to locate the corresponding relations.
 				 */
-				if (strcmp(proot->plan_name, rtinfo->plan_name) == 0
-					&& !rtinfo->dummy)
+				if (rtinfo->dummy)
 				{
-					rtoffset = rtinfo->rtoffset;
-					Assert(rtoffset > 0);
+					/*
+					 * It will not be possible to make any effective use of the
+					 * sj_unique_rels list in this case, and it also won't be
+					 * important to do so. So just throw the list away to avoid
+					 * confusing pgpa_plan_walker.
+					 */
+					proot->sj_unique_rels = NIL;
 					break;
 				}
+				Assert(!proot->has_rtoffset);
+				proot->has_rtoffset = true;
+				proot->rtoffset = rtinfo->rtoffset;
+				break;
 			}
-
-			/*
-			 * It's not an error if we don't find the plan name: that just
-			 * means that we planned a subplan by this name but it ended up
-			 * being a dummy subplan and so wasn't included in the final plan
-			 * tree.
-			 */
-			if (rtoffset == 0)
-				continue;
 		}
+	}
+}
+
+/*
+ * Validate that the range table identifiers we were able to generate during
+ * planning match the ones we generated from the final plan.
+ */
+static void
+pgpa_validate_rt_identifiers(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	Index		rtable_length = list_length(pstmt->rtable);
+
+	/* Create identifiers from the planned statement. */
+	rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+
+	/* Iterate over identifiers created during planning, so we can compare. */
+	foreach_ptr(pgpa_planner_info, proot, pps->proots)
+	{
+		if (!proot->has_rtoffset)
+			continue;
 
 		for (int rti = 1; rti <= proot->rid_array_size; ++rti)
 		{
-			Index		flat_rti = rtoffset + rti;
+			Index		flat_rti = proot->rtoffset + rti;
 			pgpa_identifier *rid1 = &proot->rid_array[rti - 1];
 			pgpa_identifier *rid2;
 
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
index e9045f69bca..93fda2055b2 100644
--- a/contrib/pg_plan_advice/pgpa_planner.h
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -24,11 +24,33 @@ typedef struct pgpa_planner_info
 	/* Plan name taken from the corresponding PlannerInfo; NULL at top level. */
 	char	   *plan_name;
 
-#ifdef USE_ASSERT_CHECKING
+	/*
+	 * If the corresponding PlannerInfo has an alternative_root, then this is
+	 * the plan name from that PlannerInfo; otherwise, it is the same as
+	 * plan_name.
+	 *
+	 * is_alternative_plan is set to true for every pgpa_planner_info that
+	 * shares an alternative_plan_name with at least one other, and to false
+	 * otherwise.
+	 */
+	char	   *alternative_plan_name;
+	bool		is_alternative_plan;
+
 	/* Relation identifiers computed for baserels at this query level. */
 	pgpa_identifier *rid_array;
 	int			rid_array_size;
-#endif
+
+	/*
+	 * If has_rtoffset is true, then rtoffset is the offset required to align
+	 * RTIs for this query level with RTIs from the final, flattened rangetable.
+	 * If has_rtoffset is false, then this subquery's range table wasn't copied,
+	 * or was only partially copied, into the final range table. (Note that
+	 * we can't determine the rtoffset values until the final range table
+	 * actually exists; before that time, has_rtoffset will be false everywhere
+	 * except at the top level.)
+	 */
+	bool		has_rtoffset;
+	Index		rtoffset;
 
 	/*
 	 * List of Bitmapset objects. Each represents the relid set of a relation
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
index 634ec5c4c6e..7ade0b5ca9c 100644
--- a/contrib/pg_plan_advice/pgpa_trove.c
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -162,6 +162,7 @@ pgpa_build_trove(List *advice_items)
 				break;
 
 			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_DO_NOT_SCAN:
 			case PGPA_TAG_INDEX_ONLY_SCAN:
 			case PGPA_TAG_INDEX_SCAN:
 			case PGPA_TAG_SEQ_SCAN:
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index 6fbc784bf54..0a4512d4921 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -59,6 +59,10 @@ static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
 									  Bitmapset *relids);
 static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
 										   Bitmapset *relids);
+static void pgpa_classify_alternative_subplans(pgpa_plan_walker_context *walker,
+											   List *proots,
+											   List **chosen_proots,
+											   List **discarded_proots);
 
 /*
  * Top-level entrypoint for the plan tree walk.
@@ -75,6 +79,8 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 	ListCell   *lc;
 	List	   *sj_unique_rtis = NULL;
 	List	   *sj_nonunique_qfs = NULL;
+	List	   *chosen_proots;
+	List	   *discarded_proots;
 
 	/* Initialization. */
 	memset(walker, 0, sizeof(pgpa_plan_walker_context));
@@ -95,42 +101,23 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 	/* Adjust RTIs from sj_unique_rels for the flattened range table. */
 	foreach_ptr(pgpa_planner_info, proot, proots)
 	{
-		int			rtoffset = 0;
-		bool		dummy = false;
-
 		/* If there are no sj_unique_rels for this proot, we can skip it. */
 		if (proot->sj_unique_rels == NIL)
 			continue;
 
 		/* If this is a subplan, find the range table offset. */
-		if (proot->plan_name != NULL)
-		{
-			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
-			{
-				if (strcmp(proot->plan_name, rtinfo->plan_name) == 0)
-				{
-					rtoffset = rtinfo->rtoffset;
-					dummy = rtinfo->dummy;
-					break;
-				}
-			}
-
-			if (rtoffset == 0)
-				elog(ERROR, "no rtoffset for plan %s", proot->plan_name);
-		}
+		if (!proot->has_rtoffset)
+			elog(ERROR, "no rtoffset for plan %s", proot->plan_name);
 
-		/* If this entry pertains to a dummy subquery, ignore it. */
-		if (dummy)
-			continue;
-
-		/* Offset each relid set by the rtoffset we just computed. */
+		/* Offset each relid set by the proot's rtoffset. */
 		foreach_node(Bitmapset, relids, proot->sj_unique_rels)
 		{
 			int			rtindex = -1;
 			Bitmapset  *flat_relids = NULL;
 
 			while ((rtindex = bms_next_member(relids, rtindex)) >= 0)
-				flat_relids = bms_add_member(flat_relids, rtindex + rtoffset);
+				flat_relids = bms_add_member(flat_relids,
+											 rtindex + proot->rtoffset);
 
 			sj_unique_rtis = lappend(sj_unique_rtis, flat_relids);
 		}
@@ -193,6 +180,42 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 
 		walker->query_features[t] = query_features;
 	}
+
+	/* Classify alternative subplans. */
+	pgpa_classify_alternative_subplans(walker, proots,
+									   &chosen_proots, &discarded_proots);
+
+	/*
+	 * Figure out which of the discarded alternatives have a non-discarded
+	 * alternative. Those are the ones for which we want to emit DO_NOT_SCAN
+	 * advice. (If every alternative was discarded, then there's no point.)
+	 */
+	foreach_ptr(pgpa_planner_info, discarded_proot, discarded_proots)
+	{
+		bool		some_alternative_chosen = false;
+
+		foreach_ptr(pgpa_planner_info, chosen_proot, chosen_proots)
+		{
+			if (strings_equal_or_both_null(discarded_proot->alternative_plan_name,
+										   chosen_proot->alternative_plan_name))
+			{
+				some_alternative_chosen = true;
+				break;
+			}
+		}
+
+		if (some_alternative_chosen)
+		{
+			for (int rti = 1; rti <= discarded_proot->rid_array_size; rti++)
+			{
+				pgpa_identifier *rid = &discarded_proot->rid_array[rti - 1];
+
+				if (rid->alias_name != NULL)
+					walker->do_not_scan_identifiers =
+						lappend(walker->do_not_scan_identifiers, rid);
+			}
+		}
+	}
 }
 
 /*
@@ -697,6 +720,30 @@ pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
 		return false;
 	}
 
+	/*
+	 * DO_NOT_SCAN advice targets rels that may not be in the flat range table
+	 * (e.g. MinMaxAgg losers), so we can't use pgpa_compute_rti_from_identifier.
+	 * Instead, check directly against the do_not_scan_identifiers list.
+	 */
+	if (tag == PGPA_TAG_DO_NOT_SCAN)
+	{
+		if (target->ttype != PGPA_TARGET_IDENTIFIER)
+			return false;
+		foreach_ptr(pgpa_identifier, rid, walker->do_not_scan_identifiers)
+		{
+			if (strcmp(rid->alias_name, target->rid.alias_name) == 0 &&
+				rid->occurrence == target->rid.occurrence &&
+				strings_equal_or_both_null(rid->partnsp,
+										   target->rid.partnsp) &&
+				strings_equal_or_both_null(rid->partrel,
+										   target->rid.partrel) &&
+				strings_equal_or_both_null(rid->plan_name,
+										   target->rid.plan_name))
+				return true;
+		}
+		return false;
+	}
+
 	if (target->ttype == PGPA_TARGET_IDENTIFIER)
 	{
 		Index		rti;
@@ -730,6 +777,10 @@ pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
 			/* should have been handled above */
 			pg_unreachable();
 			break;
+		case PGPA_TAG_DO_NOT_SCAN:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
 		case PGPA_TAG_BITMAP_HEAP_SCAN:
 			return pgpa_walker_find_scan(walker,
 										 PGPA_SCAN_BITMAP_HEAP,
@@ -1035,3 +1086,60 @@ pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
 {
 	return bms_is_subset(relids, walker->no_gather_scans);
 }
+
+/*
+ * Classify alternative subplans as chosen or discarded.
+ */
+static void
+pgpa_classify_alternative_subplans(pgpa_plan_walker_context *walker,
+								   List *proots,
+								   List **chosen_proots,
+								   List **discarded_proots)
+{
+	Bitmapset  *all_scan_rtis = NULL;
+
+	/* Initialize both output lists to empty. */
+	*chosen_proots = NIL;
+	*discarded_proots = NIL;
+
+	/* Collect all scan RTIs. */
+	for (int s = 0; s < NUM_PGPA_SCAN_STRATEGY; s++)
+		foreach_ptr(pgpa_scan, scan, walker->scans[s])
+			all_scan_rtis = bms_add_members(all_scan_rtis, scan->relids);
+
+	/* Now classify each subplan. */
+	foreach_ptr(pgpa_planner_info, proot, proots)
+	{
+		bool		chosen = false;
+
+		/*
+		 * We're only interested in classifying subplans for which there are
+		 * alternatives.
+		 */
+		if (!proot->is_alternative_plan)
+			continue;
+
+		/*
+		 * A subplan has been chosen if any of its scan RTIs appear in the
+		 * final plan. This cannot be the case if it has no RT offset.
+		 */
+		if (proot->has_rtoffset)
+		{
+			for (int rti = 1; rti <= proot->rid_array_size; rti++)
+			{
+				if (proot->rid_array[rti - 1].alias_name != NULL &&
+					bms_is_member(proot->rtoffset + rti, all_scan_rtis))
+				{
+					chosen = true;
+					break;
+				}
+			}
+		}
+
+		/* Add it to the correct list. */
+		if (chosen)
+			*chosen_proots = lappend(*chosen_proots, proot);
+		else
+			*discarded_proots = lappend(*discarded_proots, proot);
+	}
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
index 9b74cd3ba55..47667c03374 100644
--- a/contrib/pg_plan_advice/pgpa_walker.h
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -100,6 +100,7 @@ typedef struct pgpa_plan_walker_context
 	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
 	List	   *query_features[NUM_PGPA_QF_TYPES];
 	List	   *future_query_features;
+	List	   *do_not_scan_identifiers;
 } pgpa_plan_walker_context;
 
 extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
diff --git a/contrib/pg_plan_advice/sql/alternatives.sql b/contrib/pg_plan_advice/sql/alternatives.sql
new file mode 100644
index 00000000000..16299edd196
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/alternatives.sql
@@ -0,0 +1,58 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE alt_t1 (a int) WITH (autovacuum_enabled = false);
+CREATE TABLE alt_t2 (a int) WITH (autovacuum_enabled = false);
+CREATE INDEX ON alt_t2(a);
+INSERT INTO alt_t1 SELECT generate_series(1, 1000);
+INSERT INTO alt_t2 SELECT generate_series(1, 100000);
+VACUUM ANALYZE alt_t1;
+VACUUM ANALYZE alt_t2;
+
+-- This query uses an OR to prevent the EXISTS from being converted to a
+-- semi-join, forcing the planner through the AlternativeSubPlan path.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM alt_t1
+WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
+
+-- We should be able to force either AlternativeSubPlan by advising against
+-- scanning the other relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM alt_t1
+WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM alt_t1
+WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
+COMMIT;
+
+-- Now let's test a case involving MinMaxAggPath, which we treat similarly
+-- to the AlternativeSubPlan case.
+CREATE TABLE alt_minmax (a int) WITH (autovacuum_enabled = false);
+CREATE INDEX ON alt_minmax(a);
+INSERT INTO alt_minmax SELECT generate_series(1, 10000);
+VACUUM ANALYZE alt_minmax;
+
+-- Using an Index Scan inside of an InitPlan should win over a full table
+-- scan.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+
+-- Advising against the scan of alt_minmax at the root query level should
+-- change nothing, but if we say we don't want either of or both of the
+-- minmax-variant scans, the plan should switch to a full table scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1) DO_NOT_SCAN(alt_minmax@minmax_2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT min(a), max(a) FROM alt_minmax;
+COMMIT;
+
+DROP TABLE alt_t1, alt_t2, alt_minmax;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
index 4fc494c7d8e..800ff7a4622 100644
--- a/contrib/pg_plan_advice/sql/scan.sql
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -79,7 +79,8 @@ COMMIT;
 
 -- We can force a primary key lookup to use a sequential scan, but we
 -- can't force it to use an index-only scan (due to the column list)
--- or a TID scan (due to the absence of a TID qual).
+-- or a TID scan (due to the absence of a TID qual). If we apply DO_NOT_SCAN
+-- here, we should get a valid plan anyway, but with the scan disabled.
 BEGIN;
 SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
@@ -87,6 +88,8 @@ SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
 SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
 COMMIT;
 
 -- We can forcibly downgrade an index-only scan to an index scan, but we can't
diff --git a/doc/src/sgml/pgplanadvice.sgml b/doc/src/sgml/pgplanadvice.sgml
index 8df8a978ecf..c3e1ccb60a2 100644
--- a/doc/src/sgml/pgplanadvice.sgml
+++ b/doc/src/sgml/pgplanadvice.sgml
@@ -267,7 +267,8 @@ TID_SCAN(<replaceable>target</replaceable> [ ... ])
 INDEX_SCAN(<replaceable>target</replaceable> <replaceable>index_name</replaceable> [ ... ])
 INDEX_ONLY_SCAN(<replaceable>target</replaceable> <replaceable>index_name</replaceable> [ ... ])
 FOREIGN_SCAN((<replaceable>target</replaceable> [ ... ]) [ ... ])
-BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
+BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])
+DO_NOT_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
 
    <para>
     <literal>SEQ_SCAN</literal> specifies that each target should be
@@ -297,6 +298,17 @@ BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
     that purpose.
    </para>
 
+   <para>
+    <literal>DO_NOT_SCAN</literal> specifies that a particular target
+    should not appear in the final plan at all. In most cases, this is
+    impossible, and will simply cause the scan of the target relation to
+    be marked disabled. However, in certain cases, the planner considers
+    optimizations where a portion of the plan tree is copied and mutated,
+    and then considered as an alternative to the original. In those cases,
+    <literal>DO_NOT_SCAN</literal> can be used to exclude the non-preferred
+    alternative.
+   </para>
+
    <para>
     The planner supports many types of scans other than those listed here;
     however, in most of those cases, there is no meaningful decision to be
-- 
2.51.0

